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.
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.
-
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_addresshas to return exactly the XMR address stored on the customer's wallet row, and it is asked again before every read and before eachtransferandrelay_tx. -
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_balancereports the total and unlocked balance, andget_fee_estimateon the daemon, multiplied by an assumed 2,000-byte transaction, gives an estimated network fee. The estimate decides one thing: whether the unlocked balance coversamountplus the platform's cut plus that fee. If it does not, the withdrawal is held — see Locked outputs below. -
Build a test transaction and do not send it. A
transferwithdo_not_relay: true, carrying the fullamountand the platform's cut, returns the exact network fee for the outputs the wallet would spend. -
Recompute with the exact fee. The platform's cut becomes
withdrawalFee − actualNetworkFee, never below zero. If the total balance cannot coveramountplus 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. -
Build the real transaction, still without relaying it. A second
transfer, withdo_not_relay: trueandget_tx_metadata: true, returns the signed transaction and its hash. -
Claim the relay. The row's
txHashPendingcolumn is set to that hash, andmetadata.xmrRelayClaimrecords the hash, the session number and the time — but only while the row is still in the state read in step 1, with notrxIdand no earlier claim. If another attempt got there first, this one stops and sends nothing. -
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_txsends 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. -
Record the outcome. The row becomes
COMPLETEDwith the hash as itstrxId, but only over this attempt's own claim. If the row changed while the relay ran, it is set toTIMEOUTinstead 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
PENDINGthree 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 saysTransaction already processed or in process, thendropped 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 startA 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_addressstraight afteropen_walletreturns a different address, logged asWALLET 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
trxIdor atxHashPendingnever 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 atrxIdis treated as already sent. A row with only atxHashPendingis dropped by the queue untouched, so it does not requeue: it staysPROCESSING, the watchdog reports it as stale, and it waits for you. - A row whose metadata still records an earlier relay claim —
metadata.xmrRelayClaimstays 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 logsALERT 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 separateREFUNDtransaction whosereferenceIdis the withdrawal's id and whose description readsRefund 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): …. TheREFUNDtransaction 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:
-
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": trueor ablock_heightabove zero means it was broadcast; the hash listed undermissed_txmeans this daemon has never seen it. -
Found: the coins left. Set the row
COMPLETEDwith that hash as itstrxId— 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 tomaster_walletin that same transaction. -
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 toPENDINGfor 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
PROCESSINGorTIMEOUT, or that carries atrxIdor atxHashPending, with a 409 telling you to leave it for reconciliation. Nothing reconciles a Monero row: the recovery pass only flags a stalePROCESSINGone, and it never looks atTIMEOUTrows at all. - The withdraw-log editor and the transaction editor accept only
PENDINGrows ("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
COMPLETEDor carries atrxId, it stops and reports the withdrawal as already sent ("…already completed (trxId=…). Skipping re-send."). If its status is anything other thanPENDINGorPROCESSING, it stops ("…it was resolved while this attempt waited for wallet-rpc. Nothing was sent."). If it carries atxHashPendingwithout atrxId, 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 toFAILED, 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
trxIdor atxHashPending, and logs "Refusing to fail/refund …". ThetxHashPendinghalf 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.
- 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
monerodfor 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, thenmaster_walletif 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_txsequence, 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
sendsession 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
FAILEDand its obligations reopen. A relay whose answer is lost or ambiguous, and that the daemon does not confirm, parks the settlement asNEEDS_REVIEWcarrying 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.
What to check when a withdrawal is slow
Before escalating, in this order:
- Is the row
PENDINGwith a "Funds are locked" description? Then it is working. Wait; the queue will retry. - Is the row
PENDINGand 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. - Is
monerodreachable 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. - Is the wallet's unlocked balance short? Total balance is not the number that matters.
- 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.
- Is another session ahead of it? Every Monero operation, in every backend process, goes through the one wallet RPC in turn.