MMashDiv

Withdrawals

How an XMR withdrawal is priced and sent — the recipient gets the full amount and the network fee comes out of your platform fee, the 90-second wait for the shared wallet RPC, locked outputs, the pre-relay claim, where the reason of a failed withdrawal ends up, the states that are deliberately never refunded and how to resolve them by hand, and why switching the chain off does not stop withdrawals.

33 min readUpdated 1 October 2026withdrawals, fees, locked-funds, master-wallet, refunds, relay, wallet-rpc

An XMR withdrawal is signed by the customer's own wallet file, not by a platform hot wallet. There is no gas payer on this chain: Monero's network fee is paid out of the same outputs being spent. The master wallet's only role in a withdrawal is to be the destination for your platform fee.

That has a consequence you should decide on before you enable the chain: your platform's XMR revenue accrues into master_wallet, one withdrawal at a time, in the same transaction that pays the customer.

The money flow, exactly

The user asks to withdraw amount. Their ECO wallet is debited amount + withdrawalFee, where withdrawalFee is the fee configured on the XMR token record — the larger of its percentage of amount and its minimum. That is the only debit. The Monero network fee is never added to it; the fee estimate the backend makes at that moment is stored on the row for information only.

That estimate is nevertheless the request's one dependency on monerod. It is asked for before anything is debited, so while the backend cannot reach the daemon the withdraw form refuses with "Failed to withdraw: Failed to estimate Monero fee: …" — no balance changes, no transaction row is created, and the customer can submit again once the daemon answers. A withdrawal accepted earlier and still queued is a different case: it fails inside the handler and is refunded.

The request does not need the wallet RPC at all. While monero-wallet-rpc is down, new withdrawals are still accepted and debited; each then fails when the handler opens the wallet, with "Wallet RPC is not responding. Please ensure monero-wallet-rpc is running on …", and is refunded.

Then, when the queue picks the row up, one transaction leaves the customer's wallet file:

Quantity Where it goes
amount The destination address. The recipient gets the full amount
max(0, withdrawalFee − networkFee) The XMR master wallet, as a second destination in the same transaction
networkFee Monero miners, charged on top of both destinations

When withdrawalFee covers the network fee, the wallet file spends what the customer was debited, and the platform keeps withdrawalFee − networkFee. Strictly, the cut is worked out from the fee of an unsent test build (step 3 below), and the transaction that is relayed can pay a slightly different fee; that difference stays in, or comes out of, the customer's wallet file.

The platform's profit is your configured fee minus the actual network fee. Set the fee well above what Monero charges and the difference reaches master_wallet on each send. Set it below the network fee and three things happen:

  • the platform earns nothing on that withdrawal;
  • the backend logs "Network fee … exceeds the … withdrawal fee charged on transaction … The platform is absorbing … XMR. Raise the XMR withdrawal fee to cover it." The shortfall is not paid by a platform wallet: it is spent from the customer's wallet file, the only wallet the transaction draws on, so that file now holds less than the customer's balance says;
  • a later withdrawal of that customer's whole balance cannot be funded from the file. It is held as "Funds are locked" (see below) and fails and is refunded after two hours.

So treat the token's fee as a floor that must stay above the network fee, not as a revenue setting that can go to zero.

If there is no enabled ecosystem_master_wallet row for chain XMR and the token's currency, the addon logs a warning, sets the admin profit to zero and sends the withdrawal anyway: the full amount to the recipient, the network fee on top, nothing to the master wallet. Your fee stays behind in the customer's wallet file, on every withdrawal, until somebody notices.

Create the XMR master wallet during install, and check it is status: true.

How a withdrawal is built and sent

Monero fees depend on the transaction the wallet actually builds, and a relay cannot be taken back. So the handler builds before it sends, and it records its intent in the database before it relays.

  1. Take the wallet RPC. The withdrawal waits — at most 90 seconds, see Waiting for the wallet RPC below — for the cluster-wide wallet-rpc session, re-reads the row under that lock, and opens the customer's wallet file. The file must prove it is the wallet on record: get_address has to return exactly the XMR address stored on the customer's wallet row, and it is asked again before every read and before each transfer and relay_tx.

  2. Refresh, read the balance, estimate. The wallet syncs first, with a two-minute budget if this process refreshed it in the last six hours and fifteen minutes if not. Then get_balance reports the total and unlocked balance, and get_fee_estimate on the daemon, multiplied by an assumed 2,000-byte transaction, gives an estimated network fee. The estimate decides one thing: whether the unlocked balance covers amount plus the platform's cut plus that fee. If it does not, the withdrawal is held — see Locked outputs below.

  3. Build a test transaction and do not send it. A transfer with do_not_relay: true, carrying the full amount and the platform's cut, returns the exact network fee for the outputs the wallet would spend.

  4. Recompute with the exact fee. The platform's cut becomes withdrawalFee − actualNetworkFee, never below zero. If the total balance cannot cover amount plus the cut plus the fee, the withdrawal fails with "Insufficient funds." and the customer is refunded. The figures are only in the log, as "Insufficient funds in wallet … Need … XMR, have … XMR", and the reason does not stay on the row either: once the refund has run, the description reads "Refund of …" — see What a failed withdrawal leaves behind below.

  5. Build the real transaction, still without relaying it. A second transfer, with do_not_relay: true and get_tx_metadata: true, returns the signed transaction and its hash.

  6. Claim the relay. The row's txHashPending column is set to that hash, and metadata.xmrRelayClaim records the hash, the session number and the time — but only while the row is still in the state read in step 1, with no trxId and no earlier claim. If another attempt got there first, this one stops and sends nothing.

  7. Prove once more, then relay once. The open wallet is checked again and Redis is asked whether the lock is still this session's. Then relay_tx sends the exact transaction built in step 5: one attempt, 120 seconds, never retried. A failed check here gives the claim back; nothing has been sent.

  8. Record the outcome. The row becomes COMPLETED with the hash as its trxId, but only over this attempt's own claim. If the row changed while the relay ran, it is set to TIMEOUT instead and nothing is refunded.

Until relay_tx is sent nothing has moved: a failure requeues the row, holds it, or fails and refunds it — or, when another attempt has already resolved or claimed the row, leaves it alone. From relay_tx on, only an answer that proves nothing was broadcast leads to a refund — see Relay outcomes below.

Waiting for the wallet RPC

monero-wallet-rpc holds one wallet open at a time, and every backend process — web and cron — shares it under a cluster-wide Redis lock (how the lock works is on Deposits and confirmations). A withdrawal is one session under that lock, and it waits for it deliberately briefly:

  • At most 90 seconds, counted from when the queue hands it over. The budget covers the wait behind this process's own Monero work as well as the wait for the lock, because the withdrawal queue runs one withdrawal at a time for every chain in the process and a long wait here would stall them all.

  • The session holds that queue too. The short budget only bounds the wait. Once a withdrawal has the wallet RPC, the queue starts no other withdrawal, of any chain, until this one's session has ended: the wallet refresh (up to fifteen minutes on a dormant wallet), the test build and the real build, the relay, and up to fifteen more minutes waiting out a call that timed out. How long that delays the BTC, ETH or other withdrawals queued behind it depends on the deployment. With the default two processes it is a few minutes: the cron process's watchdog runs every five minutes, takes any withdrawal still PENDING three minutes after it was created that is not in its own queue — it cannot see the web process's — and runs it itself. When the web queue reaches that row later, its claim matches nothing and the row is dropped from that queue unchanged (the log says Transaction already processed or in process, then dropped from local queue). Only on a single-process deployment, or with the cron process stopped, do the others wait out the whole session — up to about three quarters of an hour.

  • Busy is not a failure. A withdrawal that does not get the wallet RPC in time goes back to PENDING, and the withdrawal watchdog, which runs every five minutes, hands it to the queue again. Nothing is failed, refunded or emailed. The same happens when the session cannot start because Redis is unreachable (XMR_SESSION_UNAVAILABLE), when it loses its lock before the relay (XMR_SESSION_LOST), and when the open wallet cannot be proved to be the customer's (XMR_WALLET_IDENTITY). The log says so every time:

    Withdrawal <id> deferred, nothing was sent: XMR_SESSION_BUSY: monero-wallet-rpc is in another session (<holder>); withdrawal of wallet <wallet-id> waited <n> s and did not start

    A withdrawal that timed out behind this process's own Monero work reads "…waited 90 s behind another wallet-rpc session of this process and did not start" instead.

  • One full wait per busy spell. When one withdrawal has waited its whole 90 seconds in vain, the others in that process only try for the next 90 seconds, without waiting, and are requeued at once if the wallet RPC is still busy. Their line ends "(it only tried: another withdrawal waited its 90 s for wallet-rpc in vain N s ago)". The first withdrawal that gets in ends the spell.

A refusal that retrying cannot cure

Two refusals are facts about the wallet row or the file, not about the moment:

  • the wallet row records no XMR address to prove its file against — "…has no recorded XMR address to prove its wallet file against; nothing was opened";
  • the file opens as another wallet — get_address straight after open_wallet returns a different address, logged as WALLET IDENTITY MISMATCH wallet=… expected=… got=… fence=… (right after open_wallet).

Both are raised before anything is built, and both would otherwise requeue for ever with the customer already debited. So each one is stamped on the row (metadata.xmrPersistentRefusal: a reason code, the first and last time, a count). Once the refusals have gone on for XMR_WITHDRAWAL_REFUSAL_FAIL_AFTER_HOURS — 24 hours unless you set it — the handler marks the row FAILED, logs an ALERT withdrawal … Marked FAILED line, and hands the queue a plain reason: "Withdrawal failed: the Monero wallet file of this account does not match its recorded address, and it has been refused for 24 h. Nothing was sent; the withdrawal is refunded." — or, for a row with no address, "…the Monero wallet of this account has no recorded address to verify it against…". The queue then refunds the customer. A gap of more than six hours between two refusals starts the clock again.

That sentence does not stay on the row. The refund rewrites the description to "Refund of …", as it does for every failed withdrawal (see What a failed withdrawal leaves behind below), so neither "does not match its recorded address" nor "refused for 24 h" can be found there afterwards. The plain reason goes to the customer in the failure email and to you in the log line tagged WITHDRAW, Failed to process transaction <id>: Withdrawal failed: …. What the row keeps is metadata.xmrPersistentRefusal, with the reason code — file_is_another_wallet or no_recorded_address — and now a failedAt time. The ALERT line names the same code and quotes the last refusal as the session reported it.

The bound only ever fails a row for which nothing can have been relayed, and that excludes two kinds of row in two different ways:

  • A row with a trxId or a txHashPending never reaches these refusals at all. The check at the start of the session stops it before any wallet is opened (see Idempotency and the failure guards below). A row with a trxId is treated as already sent. A row with only a txHashPending is dropped by the queue untouched, so it does not requeue: it stays PROCESSING, the watchdog reports it as stale, and it waits for you.
  • A row whose metadata still records an earlier relay claim — metadata.xmrRelayClaim stays on the row even after a claim was given back — does meet them, and is never failed for them. It keeps being requeued, and once the row is older than the bound, counted from its creation rather than from the first refusal, every further refusal logs ALERT withdrawal … refused (…) and N h old, but it carries a relay claim (…) in its metadata, so it is never failed or refunded automatically. Check that hash on the chain, fix the wallet, then resolve the row by hand.

Either way, fix the wallet row or the file before the customer withdraws again. Troubleshooting shows how to look inside a wallet file without touching the live wallet RPC.

Address validation

Before anything is queued, the destination must parse as a real Monero address: 95 characters (106 for an integrated address), a known network byte, and the checksum every Monero address carries. A string that fails is refused with "Invalid Monero address: … It is not a well-formed Monero address — check for a typo or a truncated paste." The checksum catches almost every typo before the customer is debited. What no check can tell is whether the address belongs to the person the customer meant.

A well-formed address for another network is refused with "This is a Monero stagenet address, but this platform is configured for mainnet. Use a mainnet address, or ask an administrator to check XMR_NETWORK." The network the platform is "configured for" is XMR_NETWORK, and mainnet when it is unset. The value is trimmed and lower-cased before it is read here, so MAINNET or Stagenet is enforced as that network. A name that is none of the three — main, say — enforces nothing: the backend logs an error and lets every well-formed address through, whichever network it names.

Write the exact lowercase name all the same. The check that offers XMR for deposit compares the same variable with the token row's network letter for letter, so XMR_NETWORK="MAINNET" validates withdrawals correctly while hiding XMR from the deposit currency list of a row labelled mainnet.

If XMR_NETWORK disagrees with the network monerod actually runs, every genuine address is refused — see Configuring the RPC connection.

Locked outputs: the hold, not a failure

Monero locks received outputs for roughly ten blocks — about twenty minutes — at the protocol level. This is independent of the addon's six-confirmation rule for crediting deposits, and it is the single most common reason a withdrawal does not go out immediately.

When the wallet's unlocked balance is short of amount plus the platform's cut plus the estimated network fee, the addon does not fail the withdrawal. It sets the row back to PENDING with a description naming the unlocked and total balances — "Funds are locked (x/y XMR unlocked). Waiting to process." — and hands it back to the withdrawal queue's watchdog to retry later.

The same treatment applies when the do_not_relay pass itself reports "not enough unlocked money" while the total balance is sufficient.

The hold is bounded. Two hours after the transaction row was created, a still-locked withdrawal fails cleanly and is refunded rather than being held indefinitely. Note that the first check compares the unlocked balance only, so a withdrawal the file could not fund even with everything unlocked — the shortfall case described under The money flow — is held the same way and fails at the same two-hour mark.

The handler does not schedule its own retry. Re-entering through the queue is what guarantees a single owner for the row — a self-scheduled retry could run alongside a queue-driven one and broadcast the payment twice.

Relay outcomes

relay_tx is the only call that moves coins, and its answer is read for what it proves:

What came back What it proves What happens to the row
Refused before it was sent (the session lost its lock) Nothing reached the wallet RPC Claim given back, requeued
Error -7, -13, -26, -27, or a JSON-RPC -32xxx Refused before the wallet committed anything Claim given back, FAILED and refunded
-4 "Failed to commit tx.", or any other error The wallet may have broadcast before it failed The daemon is asked; if it has the hash the row is COMPLETED, otherwise TIMEOUT with the claim kept
No answer — a timeout, a reset or a refused connection The answer is lost, not necessarily the request The daemon is asked; if it has the hash the row is COMPLETED, otherwise TIMEOUT with the claim kept
An answer without a transaction hash, or any other failure after the claim The relay may have happened TIMEOUT with the claim kept

"The daemon is asked" means monerod's /get_transactions endpoint on the daemon the backend last got an answer from — XMR_DAEMON_RPC_URL on a single-daemon install, the entry of the XMR_<NETWORK>_RPC list that last answered otherwise. In the pool or in a block counts as broadcast.

-4 is also what the wallet RPC answers when its own daemon refused the transaction or could not be reached, and a monerod that is still synchronising refuses every broadcast. Nothing left the wallet in that case, but the backend cannot know it: the daemon has no record of the hash, or cannot be asked, so the row becomes TIMEOUT and it is for you to confirm the hash never appeared before you fail and refund it. Expect that outcome for every withdrawal that reaches its relay while get_info reports "synchronized": false.

relay_tx answers with the hash of what it relayed, and that is the hash written to trxId. Should it ever differ from the hash of the transaction the handler built, the row is still completed — trxId holds the relayed hash, txHashPending keeps the built one — and the log says "relay_tx for withdrawal … answered tx …, but the built transaction was …; recording the relayed hash".

After a relay that succeeded, the handler asks the wallet RPC which wallet is open. If another one is, it logs ALERT … relay_tx of … ran while wallet-rpc had … open instead of …: the coins came from the right wallet, but the wallet RPC booked the spend into the other wallet's cache, which then under-reports its balance until you run rescan_spent on that wallet file.

What a failed withdrawal leaves behind

Every XMR withdrawal that is failed and refunded ends the same way, whatever went wrong, and its row does not say what that was.

The description is rewritten at each step and only the last write survives. The handler marks the row FAILED with its own reason. The withdrawal queue, handed the same error, rewrites that to Transaction failed: <reason>. Then the refund, as its first step, rewrites it again to Refund of <amount> — the row's amount exactly as stored, eighteen decimals and no currency: "Refund of 0.500000000000000000". That is what the admin's withdraw log and the customer's transaction history show from then on.

Read that text for what it is:

  • It is not the amount that went back. It names the amount withdrawn. The refund returns the amount plus the fee (the row's metadata.totalAmount), as a separate REFUND transaction whose referenceId is the withdrawal's id and whose description reads Refund of <total> XMR for failed withdrawal. The customer's own history hides that second row and shows only the withdrawal.
  • It is not proof of a refund. It is written before the money moves. If the refund itself then fails, the row still says "Refund of …" and the log says Refund for <withdrawal id> did not apply (possibly already refunded): …. The REFUND transaction is the proof.

The reason survives in three places:

Where What it carries
The log, tag XMR Failed to execute withdrawal: <reason>, written by the handler when the failure happened inside it. A row failed by the 24-hour refusal bound has the ALERT withdrawal … Marked FAILED line instead
The log, tag WITHDRAW Failed to process transaction <withdrawal id>: <reason>, written by the queue — the one line that carries both the id and the reason
The customer's failure email, where outgoing mail is working The same reason, word for word, on its "Reason" line

The in-app "Withdrawal Failed" notification carries the amount and no reason.

The email quotes the handler's error as it is, so the customer reads operator wording: a withdrawal that failed because the wallet RPC was down is explained to them as "Wallet RPC is not responding. Please ensure monero-wallet-rpc is running on" followed by your XMR_WALLET_RPC_URL.

The states that are never refunded

Most withdrawal failures mark the row FAILED and refund the customer. These do not, on purpose.

TIMEOUT — the outcome is unknown. The description says which case it is: "Withdrawal broadcast status unknown (response lost). Manual review required. … Built tx: <hash>", "Withdrawal outcome unknown after its relay was claimed (tx <hash>): … Manual review required.", or "Withdrawal relayed as <hash>, but the row changed while the relay ran. Manual review required." Retrying could pay twice and refunding could pay twice, so the customer gets a "Withdrawal Under Review" notification and nothing else happens automatically.

Nobody on your side is told. That notification goes to the customer only; no administrator gets a notification or an email for a TIMEOUT row. You find one in two places: the Ecosystem admin dashboard, which counts it under Payouts needing review as "Outcome unknown", and the logs of both backend processes, where every path that leads here writes a line containing NOT refunding. Make one of them part of your routine.

Resolve it by hand, starting from the hash — it is in txHashPending and in the description:

  1. Look the hash up. On a block explorer, or on your own daemon:

    curl -s -X POST http://127.0.0.1:18081/get_transactions \
      -H 'Content-Type: application/json' \
      --digest -u "$XMR_RPC_USER:$XMR_RPC_PASSWORD" \
      -d '{"txs_hashes":["<hash>"],"decode_as_json":false}'

    "in_pool": true or a block_height above zero means it was broadcast; the hash listed under missed_tx means this daemon has never seen it.

  2. Found: the coins left. Set the row COMPLETED with that hash as its trxId — in the database, see Resolving a row by hand below — then book the platform fee yourself, see Recording the platform fee. A row completed by hand has no fee record, although the platform's cut, if there was one, went to master_wallet in that same transaction.

  3. Not found: do not refund on one look. Check again later, and confirm on a copy of the customer's wallet file that the funds are still there — Troubleshooting shows how — before you fail the row and give the customer their money back by hand.

Do not re-run the withdrawal. The handler refuses a TIMEOUT row outright, and a row that carries a txHashPending without a trxId is refused with "carries an unfinished relay claim for tx … It may already be on-chain: NOT sending again."

Stale PROCESSING with no transaction hash. The watchdog's recovery pass runs where the scheduler runs — the cron process in the default deployment — every five minutes. It looks at PROCESSING rows that have no trxId, are not in that process's own queue, and were created more than five minutes ago. For UTXO chains it can check the chain and decide. For Monero — as for Solana, TON, Tron and EVM — it logs "stale PROCESSING with no trxId … Manual review required" and leaves the row alone rather than risk a second broadcast.

Read that line as a prompt to look, not as proof that a row is stuck. The age is counted from the row's creation, not from when it entered PROCESSING, and the pass cannot see what the other process is doing. So a withdrawal the web process is still working on is reported in exactly the same words once the row is five minutes old: a first attempt inside the fifteen-minute refresh of a long-dormant wallet is the usual case, one that waited behind other withdrawals in the web process's queue is another. The Ecosystem dashboard applies the same rule and lists the row under Payouts needing review as "Abandoned by recovery". The line repeats on every pass while the row stays PROCESSING, and a live attempt ends by itself — it cannot last longer than about three quarters of an hour (a 90-second wait, a session capped at thirty minutes, and at most fifteen minutes waiting out a call that timed out). Act on a row that is still PROCESSING after that.

What you then do depends on txHashPending:

  • Empty: the row never reached relay_tx — every relay is preceded by the claim — so nothing was sent. Returning it to PENDING for the queue to retry, or failing and refunding it, are both safe, and both are done by hand.
  • Set: a relay was claimed and its outcome never recorded, typically because a process died in between. Treat it exactly like TIMEOUT: check the hash first.

Both states are correct, and both mean an operator has to look. Watch for them rather than assuming the queue self-heals.

Resolving a row by hand

No screen in the admin panel can carry out any of the steps above on a TIMEOUT or PROCESSING XMR row:

  • Approve refuses every Ecosystem withdrawal: "Ecosystem withdrawals are settled on-chain by the ecosystem queue and cannot be approved here."
  • Reject refuses a row that is PROCESSING or TIMEOUT, or that carries a trxId or a txHashPending, with a 409 telling you to leave it for reconciliation. Nothing reconciles a Monero row: the recovery pass only flags a stale PROCESSING one, and it never looks at TIMEOUT rows at all.
  • The withdraw-log editor and the transaction editor accept only PENDING rows ("Only pending transactions can be updated").

So each resolution is an update of the row in the transaction table, guarded so that it cannot touch a row in any other state. Each statement must report one row changed; zero means the row is not what you checked, so read it again before doing anything else.

UPDATE `transaction`
SET status = 'COMPLETED', trxId = '<hash>'
WHERE id = '<withdrawal id>' AND status IN ('TIMEOUT', 'PROCESSING') AND trxId IS NULL;
UPDATE `transaction`
SET status = 'PENDING'
WHERE id = '<withdrawal id>' AND status = 'PROCESSING' AND trxId IS NULL AND txHashPending IS NULL;

The watchdog hands a PENDING row back to the queue within five minutes.

UPDATE `transaction`
SET status = 'FAILED'
WHERE id = '<withdrawal id>' AND status IN ('TIMEOUT', 'PROCESSING') AND trxId IS NULL;

A status change moves no money. Failing a row refunds nothing, so return the customer's debit yourself: the row's metadata.totalAmount, which is the amount plus the fee, to their ECO XMR wallet. The automatic refund puts that back on the wallet's balance and on its per-chain XMR record. The one admin door that credits a wallet, Adjust Balance under Admin → Finance → Transaction Management → Wallet Management, raises only the balance — the figure withdrawals are checked against — and records the credit on the pool-backing ledger as an admin obligation. Put the withdrawal id in its description so the entry can be explained later.

Nothing is emailed or notified for a row you change by hand, and a row you complete by hand still needs its platform fee booked — see below.

Idempotency and the failure guards

Several drivers can target the same row — the web process's queue, which is handed every new withdrawal, and the cron process's watchdog and boot-time sweep, which take retries and also any row still PENDING three minutes after it was created, so a first attempt can be the cron process's too — and only one may send:

  • Under the lock, before any wallet is opened, the handler re-reads the row. If it is COMPLETED or carries a trxId, it stops and reports the withdrawal as already sent ("…already completed (trxId=…). Skipping re-send."). If its status is anything other than PENDING or PROCESSING, it stops ("…it was resolved while this attempt waited for wallet-rpc. Nothing was sent."). If it carries a txHashPending without a trxId, it stops and logs the unfinished claim. The queue drops the last two without touching the row; it does not requeue them, fail them or refund them.
  • The relay claim itself is conditional, so two attempts that both got past the first check cannot both relay: the second finds the claim taken and sends nothing.
  • Every failure update is written with where { id, trxId: null, txHashPending: null }. A row with a hash — sent, or claimed for sending — can never be flipped to FAILED, so a customer whose coins may be on-chain cannot also be refunded. If that update affects zero rows, the handler re-reads the row, and unless it is genuinely complete the withdrawal goes to manual review rather than being treated as done.
  • The withdrawal queue has its own last line: it refuses to fail or refund any row with a trxId or a txHashPending, and logs "Refusing to fail/refund …". The txHashPending half ships in Ecosystem v6.5.3 and later; on an older Ecosystem only the handler's own conditions above protect a claimed row.

Recording the platform fee

After a completed send, the addon books the collected profit through the platform-fee path: an adminProfit entry against chain XMR, type WITHDRAW, and a PLATFORM_FEE credit to the super admin's ECO XMR wallet, referenced <withdrawal id>_fee. Its metadata carries the fee charged, the actual network fee and the wallet-rpc session number that sent it. The generic withdrawal queue deliberately skips its own profit recording for Monero, because the split between network fee and platform profit is only known inside the Monero handler.

The description on that record carries three numbers — the fee charged, the network fee and the profit — which is what you want when reconciling a month of XMR withdrawals against the master wallet's balance. They need not add up to the last digit. The profit was fixed from the fee of the unsent test build (step 3 above), while the network fee printed is the fee of the transaction that was relayed (step 5), and the two can differ slightly: the test build carried the estimated cut, or none. The profit is what actually went to master_wallet.

A record is written only when there was something to collect — an enabled XMR master wallet, and a fee larger than the network fee of the test build, since that is the fee the cut was worked out from — and in exactly one place: by the handler, straight after its own COMPLETED write. A fee that only equals that network fee leaves a cut of zero and no record. A row that becomes COMPLETED any other way — a TIMEOUT or stale PROCESSING row you completed by hand once you had found its hash — gets no adminProfit entry and no PLATFORM_FEE credit, and nothing books it later. The coins are not affected: the platform's cut was a destination of the transaction, so it is in master_wallet whether or not the books say so. Book those rows by hand, for the amount that transaction paid to master_wallet: open a copy of master_wallet the way Troubleshooting opens a customer's wallet file, and ask get_transfer_by_txid for the hash. The row's fee minus the network fee is only an approximation of that, for the reason above, and there is no cut at all if no XMR master wallet was enabled at the time.

A fee record that fails is neither retried nor seen by the withdrawal. The fee path never throws back into the handler. It logs a warning tagged PLATFORM_FEE — Failed to collect fee: WITHDRAW <profit> XMR ref=<withdrawal id> - <error> — and the handler goes on to log the send as completed. The row stays COMPLETED and the coins are in master_wallet; only the books lack the entry, so book it by hand as above. Two more cases book nothing: a withdrawal by the Super Admin's own account, which is skipped and logged only at debug level, and an install with no Super Admin at all, which logs [CRITICAL] Dropped platform fee — no Super Admin configured with the withdrawal id as referenceId.

  1. Filter to XMR withdrawals

Sends that are not withdrawals

Pool backing can also move XMR out of custody. A settlement that ships coins from the platform's wallets to the exchange is sent by this addon, but it is not a withdrawal row, and nothing above about the queue, the statuses or the refund applies to it.

  • Which file pays. The treasury's own XMR wallet file first, when its unlocked balance covers the amount plus an estimated network fee. Otherwise master_wallet, and only if it keeps its reserve afterwards — poolBackingMasterReserve, 0.1 XMR unless you set it — which is checked before the send and again inside the session that relays. A customer's wallet file is never used.
  • How it is planned. Before anything is built, the settlement asks monerod for a fee estimate, then reads balances in up to two wallet-rpc sessions of their own: the treasury's XMR file if it has one, then master_wallet if the treasury cannot cover the amount. Each read waits up to two minutes for the wallet RPC and refreshes for up to 120 seconds. A daemon that does not answer, or a wallet RPC busy past those two minutes, stops the settlement there, unsent.
  • How it is sent. The same build, prove, relay_tx sequence, as one session that waits up to twenty minutes for the wallet RPC. One relay attempt, never retried.
  • Where it runs. In the cron process when the settlement job dispatches it, and in the web process when an administrator presses Settle now on the pool-backing console. A long send session can therefore come from either process.
  • How it ends. Every failure before the relay — no fee estimate, the wallet RPC not obtained in time, a wallet that could not be proved, too little unlocked balance, the reserve — is reported to the settlement engine as not broadcast: the settlement is FAILED and its obligations reopen. A relay whose answer is lost or ambiguous, and that the daemon does not confirm, parks the settlement as NEEDS_REVIEW carrying the hash of the transaction that was built.

The engine, its modes and what to do with a parked settlement are on Pool backing.

Stopping XMR withdrawals

Switching the Monero chain off under Admin → Ecosystem → Blockchains does not stop them. The toggle writes the chain's database row and nothing else. A backend process whose Monero service is already active never reads that row again, and nothing on the withdrawal path reads it at all — not the withdraw form's network list, not the request, not the handler — so XMR stays on offer, and XMR withdrawals are accepted, debited and sent with the chain switched off, before a restart and after one.

To refuse new requests, disable the XMR token under Admin → Ecosystem → Trading → Tokens. The withdraw form lists only enabled tokens, so XMR drops out of it, and every withdrawal request reads the token row: one that still arrives — from a form left open, or through the API — is refused with "Failed to withdraw: Token not found for chain: XMR and currency: XMR" before anything is debited. Withdrawals accepted before that are not held: a row that is queued or waiting for its retry is still sent when the queue reaches it.

Do it while the chain is still switched on. The token's status toggle and its edit form refuse every change, disabling included, while the chain's row is off. The refusal reads "XMR Blockchain is disabled" — or, on a chain row whose version is still the 0.0.1 it was created with, "Please install the latest version of the blockchain XMR to enable this token", whichever way you were switching the token. If the chain is already off, switch it back on, disable the token, then switch the chain off again.

One door does not look at the chain's row at all: the bulk status endpoint, which no admin screen calls. Sent with {"ids": ["<token id>"], "status": false}, it disables the XMR token whatever state the chain is in.

PUT/api/admin/ecosystem/token/statuspermission: edit.ecosystem.token
Sets the status of several Ecosystem tokens at once, without checking the chain's row

What to check when a withdrawal is slow

Before escalating, in this order:

  1. Is the row PENDING with a "Funds are locked" description? Then it is working. Wait; the queue will retry.
  2. Is the row PENDING and the log says "deferred, nothing was sent"? The wallet RPC was busy, or Redis was unreachable. It is retried every five minutes; see who holds the lock with the command on Troubleshooting.
  3. Is monerod reachable and synchronised? A queued withdrawal refreshes the wallet first, and a refresh that cannot reach the daemon fails the withdrawal and refunds it. The reason, "Monero daemon not reachable. Wallet cannot sync. …", is in the log and in the customer's failure email; the row itself reads "Refund of …" by then. A new request does not get that far while the backend cannot reach the daemon: the form refuses it and debits nothing.
  4. Is the wallet's unlocked balance short? Total balance is not the number that matters.
  5. Has the wallet been dormant for over six hours? The first open then allows up to fifteen minutes of sync before anything else happens. A sync that outlasts its budget — two minutes on a wallet this process refreshed recently, fifteen otherwise — does not requeue the withdrawal. It fails with "Wallet RPC is not responding. Please ensure monero-wallet-rpc is running on …" — again in the log and the email, not on the row — and the customer is refunded; they can submit it again.
  6. Is another session ahead of it? Every Monero operation, in every backend process, goes through the one wallet RPC in turn.