Deposits and confirmations
How a customer's XMR deposit address comes into existence, how the addon detects an incoming transfer without an explorer, the one wallet RPC every backend process shares under a lock, the identity and ownership checks that refuse a wrong credit, the six-confirmation rule, and the monitor lifecycle.
Monero deposit detection has no explorer, no webhook and no address index. The only thing on earth that can tell you a transaction belongs to an address is a wallet holding that address's view key. So the addon does the only thing it can: it keeps a wallet file per customer and asks each one, repeatedly, whether anything arrived.
Everything below follows from that.
One wallet file per customer wallet
The first time a user opens the XMR deposit page, the platform finds or creates
their ECO wallet for XMR and then, because the address map has no XMR entry
yet, calls create_wallet on monero-wallet-rpc with the ECO wallet's UUID as
the filename. It reads back the primary address and the mnemonic, then closes
the file, opens it again by name and asks again. Only if both answers match
does it encrypt the mnemonic with the Ecosystem vault key and write the address
map entry and a wallet_data row. A mismatch stores nothing, so a customer can
never be handed another wallet's address.
If the file already exists — an earlier attempt created it and then failed before
anything was stored — create_wallet answers "already exists", and the platform
adopts that file once it has proved it the same way, and against the address
already recorded for the wallet if there is one. For master_wallet that
adoption is logged as an ALERT, because it brings back keys an operator may
have meant to retire.
So your --wallet-dir accumulates one wallet per customer who has ever viewed
the XMR deposit page. That is the design, and it is why the wallet directory is
the single most important thing to back up on this chain — there is no HD seed
these can be re-derived from.
Every operation uses account_index: 0. The addon never calls create_address,
and it never generates an integrated address with a payment ID. A user gets one
address, permanently, and it is a standard address beginning with 4 on
mainnet.
This is why the model is one wallet per user rather than one wallet with many subaddresses: subaddress accounting would need a payment-ID or index mapping the platform does not keep.
One wallet RPC, shared by every process
monero-wallet-rpc holds one open wallet at a time, and every wallet call —
get_transfers, get_balance, transfer — is answered by whichever wallet is
open when it arrives. The default deployment runs two backend processes that
both drive it: the web process (backend in PM2: the live deposit monitor,
new withdrawals as they are requested, wallet creation and the admin's balance
reads) and the cron process (cron: the background deposit scanner, the
withdrawal watchdog — which runs every retry, and any withdrawal still waiting
three minutes after it was requested — and the dormant-wallet refresh). A
pool-backing send runs in whichever process dispatched the settlement — the cron
process for the settlement job, the web process when an administrator presses
Settle now.
So every session — open a wallet, prove it, read or send, close it — holds one Redis key for that wallet RPC, across every process:
| Part | What it is |
|---|---|
| Key | xmr:wallet-rpc:<host:port>:session, where host:port comes from XMR_WALLET_RPC_URL |
| Value | <session number>:<hostname>:<pid>:<random id> — who holds it |
| Taken with | SET NX, expiring after 30 seconds |
| Renewed | every 10 seconds, only while the key still holds this session's value |
| Released | by deleting it only if it still holds this session's value |
| Session number | xmr:wallet-rpc:<host:port>:fence, incremented on every attempt to take the lock, so numbers only ever grow |
A session whose renewal fails, or that has not renewed for 25 seconds, is marked
lost and sends nothing more; no session may run longer than 30 minutes. If
Redis is unreachable, no session starts at all — no deposit is checked and no
withdrawal is sent until it is back. If a wallet call times out on the backend's
side, the wallet RPC may still be running it, so the session keeps the lock until
the wallet RPC answers get_version again (at most 15 minutes, with an ALERT
if it never does) before it lets the next session in.
Each kind of session waits a different time for the lock:
| Session | Waits at most | If it does not get the lock |
|---|---|---|
| Live deposit check | 2 minutes | That round is skipped — neither a retry nor an empty check |
| Background scan of one wallet | 60 seconds | Nothing is read; the scanner comes back to the wallet on its normal schedule |
| Withdrawal | 90 seconds | Requeued, never failed — see Withdrawals |
| Pool-backing send | 20 minutes | Refused, and marked as never broadcast for the pool-backing engine |
| Pool backing's balance reads, before a send | 2 minutes | The settlement stops there, unsent, and is marked as never broadcast |
| A customer's XMR wallet creation, on their first visit to the deposit page | 2 minutes per attempt; a customer with no XMR wallet yet gets two attempts, one after the other | No address is issued. The page's wallet request fails with HTTP 500 "Failed to generate any addresses for the wallet" once every attempt has been refused; XMR_SESSION_BUSY is only in the backend log, on the WALLET_CREATION and WALLET lines. Opening the page again tries again |
| Creating the XMR master wallet, reading its transaction history | 2 minutes | The request fails with XMR_SESSION_BUSY |
Admin balance reads of master_wallet |
2 minutes | The read is skipped and the screen keeps the balance last stored |
| Dormant-wallet refresh | Does not wait | Skipped; tried again later |
Those are waits for the lock, not for an answer. Each backend process also runs its own Monero sessions strictly one at a time, and a session first waits its turn in that process's queue. A withdrawal's 90 seconds covers that wait as well. Every other session waits there without a limit, and the time in the table starts only when it reaches the front.
A customer's first visit to the XMR deposit page shows it most. The wallet
creation is queued in the web process behind whatever Monero work that process
already has — live deposit checks, or a withdrawal, whose session can last
fifteen minutes on the refresh of a dormant wallet alone. So the request can
take far longer than the four minutes that two refused attempts add up to, and
it then outlasts the reverse proxy in front of the backend: with the
configuration on Nginx, proxy_read_timeout
ends it after 300 seconds and the page gets a 504 rather than the 500. The
backend does not abandon the creation when that happens. It stays queued and
runs when its turn comes, and if it succeeds the address is there the next time
the page is opened.
The lock is not trusted alone. Something that ignores it — an older build of
the backend, a hand-made curl, another program — can still swap the open
wallet. So every session also proves what it is talking to:
- Identity. Straight after
open_wallet,get_addressmust return exactly the XMR address recorded on that wallet's row (formaster_wallet, on the XMR master wallet row). It is asked again immediately before everyget_transfers,get_transfer_by_txid,get_balance,transferandrelay_tx. Any difference is refused asXMR_WALLET_IDENTITY— logged asWALLET IDENTITY MISMATCH wallet=… expected=… got=… fence=…— before a single read or spend. A wallet row with no recorded XMR address is refused before anything is opened. - Ownership. Every entry in a deposit check's transfer list must name one of
the proved wallet's own account-0 addresses, and so must the record fetched
for the transfer being credited. One entry that does not is refused as
XMR_OWNERSHIP_MISMATCH— logged asOWNERSHIP MISMATCH wallet=… expected=… got=… txid=… fence=…— and nothing proved in that session is credited.
Two consequences for you:
- Restart every backend process together after an update — with the default
PM2 file,
pm2 restart backend cron. An old build left running beside a new one does not take the lock. Every process must also name the wallet RPC the same way inXMR_WALLET_RPC_URL, because the key is built from that host and port:localhost:18083and127.0.0.1:18083are two different locks. - Never drive the live wallet RPC by hand. An
open_walletorclose_walletof yours swaps the wallet under whatever session is running, and any other wallet call answers from whichever customer's wallet happens to be open. To look inside a wallet, copy its files and open the copy with a secondmonero-wallet-rpcon another port — Troubleshooting has the commands.
Detection: two paths, one lock
Every check is one wallet-rpc session: open → prove the file → refresh →
get_transfers → check every entry → prove each spendable transfer → close.
Crediting is database work, so it happens after the session has closed the wallet
and let the next session in — and not at all if anything in the session named
another wallet. Two things schedule those checks.
The live session monitor
When a user is on the deposit page, the frontend holds a WebSocket to the Ecosystem deposit endpoint, served by the web process. That registers a Monero monitor for their wallet, which joins a global monitoring queue in that process.
The processor then loops: it collects every monitored wallet whose last check
was more than five seconds ago, works through them in batches of three
(sequentially — the wallet RPC cannot parallelise), and sleeps two seconds
between passes. Each check calls get_transfers with in: true and
pending: true, and with no height filter — so the in list it gets back is
the wallet's whole incoming history, not only what is new. The request never
sets pool: true, which is where monero-wallet-rpc lists incoming transfers
that are still in the mempool: a deposit becomes visible with its first
confirmation, not before.
Every check also leaves two short-lived Redis markers, refreshed while it waits
for the lock: xmr:monitoring:<walletId>, which keeps the background scanner in
the cron process off that wallet, and xmr:monitoring-active, which stops any
dormant-wallet refresh from starting while any wallet is being live-monitored.
The refresh before get_transfers is not optional and not cosmetic. A
freshly-opened wallet answers from its on-disk cache, so without an explicit
refresh the wallet reports the world as it was at its last sync — which is how
a wallet with funds in it reports a zero balance.
The background scanner
Deposits do not stop arriving when the user closes the tab. Ecosystem's background deposit scanner, which runs in the cron process, keeps a working set of deposit addresses in Redis and sweeps them on a rate-limited schedule.
An XMR address joins the scanner's active list when the user opens the
deposit page or opens their XMR wallet on the Wallets page, and stays on it for
72 hours after the last such visit (ECOSYSTEM_SCAN_ACTIVE_TTL_MS). Signing in
and the wallet list put a customer's other addresses on the slower passive
list, but not XMR: it is excluded from that list by default
(ECOSYSTEM_SCAN_PASSIVE_SKIP_CHAINS), because the scanner's Monero lane can
afford only one pass every twenty seconds.
Customers' addresses are not the only ones on the active list. Pool backing registers its treasury's XMR address there when a settlement into the treasury is dispatched, and again each time it checks one that has not arrived. With Instant Convert installed, the Convert house's deposit addresses are registered too — its XMR address included, once the house holds an XMR wallet — and that addon registers them again on a schedule, so they never drop off.
Monero's rate is deliberately the slowest of any chain: 0.05 passes per second, one wallet sweep every twenty seconds at most, because each pass takes exclusive possession of the single wallet RPC and would otherwise starve live sessions. A sweep is a no-op while a live monitor in any process is checking that wallet, and crediting is idempotent, so the two paths cannot double-credit.
ECOSYSTEM_BACKGROUND_SCAN="false" turns the scanner off platform-wide. Do not
do that on an install with Monero enabled — it is the only thing that catches a
deposit made after the deposit page was closed.
The six-confirmation rule
A transfer is credited when confirmations >= 6 and the wallet RPC no
longer reports it as locked. Nothing lower is credited, ever, and there is no
setting that changes the number.
The second condition matters on this chain. A sender can attach an
unlock_time to a transfer, and the daemon then refuses to spend those outputs
until that height or time is reached, however many confirmations they carry.
Until Monero 6.1.8 the monitor read only the confirmation count, so such a
transfer was credited the moment it reached six confirmations and the customer
could trade or transfer a balance the platform could not yet move. The monitor
now keeps polling a confirmed-but-locked transfer and credits it when the
locked flag clears and the unlock time has passed.
Coinbase outputs are never credited. The wallet RPC lists a mined output — a
solo-mined block, or a P2Pool payout, which anyone can point at a customer's
address — in the same incoming list, typed block. It is the wallet's own
output, but it is not a deposit: it is skipped, and it does not keep a monitor
running while its lock runs down.
Monero blocks target two minutes, so six confirmations is roughly twelve to twenty minutes after the transaction is mined — longer if it sat in the mempool first. Tell your support team that number, because "my deposit is missing" at the eight-minute mark is the most common false alarm on this chain.
Below six confirmations the transfer is not ignored. Each time the confirmation
count changes, the addon broadcasts a pending update over the deposit WebSocket
carrying the transaction hash, the amount, the fee, the current confirmations
and the required six, with status PENDING. That is what drives the progress
the user sees. It is a live update only: no transaction row exists until the
deposit is credited.
The counter starts at the first confirmation. An incoming transfer that is still in the mempool is not in the lists the check asks for (see The live session monitor above), so the deposit screen shows nothing for it until it is mined — on average a couple of minutes after the sender broadcast it, sometimes much longer.
What the check's pending list does hold is the wallet's own outgoing
transfers that are not mined yet — in practice, a withdrawal that customer has
just had relayed. If their wallet is being live-monitored at that moment, the
entry is broadcast to the deposit screen as a zero-confirmation pending update —
again on every check, because a count of zero never registers as already sent —
and it keeps the monitor running until the withdrawal is mined. After that no
further update comes for it, so an open screen keeps showing zero confirmations
until it is reopened. Nothing is ever
credited for it: only an in entry, proved again by get_transfer_by_txid as an
incoming transfer, can be credited.
What a credited deposit records
Once six confirmations are reached and the transfer is spendable, the addon
fetches the transfer's own record with get_transfer_by_txid, checks it names
one of the proved wallet's addresses, and — after the session has ended — writes
one transaction:
| Field | Value |
|---|---|
type |
DEPOSIT |
status |
COMPLETED — written directly; there is no PENDING row while a deposit confirms |
trxId |
The Monero transaction id |
amount |
The transfer's piconero amount divided by 10^12, to eight decimal places |
fee |
0, always. The network fee of the sender's transaction is read from the wallet RPC but not stored on the deposit row |
description |
"Deposit of … XMR from N/A" |
metadata.chain |
XMR |
metadata.from |
N/A |
metadata.to |
The exact address the wallet RPC says received the transfer |
- A Tron deposit appears already COMPLETED
- Amount and fee are DECIMAL(36,18) and arrive as strings
Two of those rows deserve a note.
from is N/A and always will be. Monero transactions do not reveal a
sender. If your compliance process expects a source address on every deposit,
this chain cannot supply one — decide that before you enable it, not during an
audit.
Amounts are stored to eight decimal places although Monero has twelve. The last four digits of a piconero amount are not retained on the transaction record. In practice this only matters for dust; it is not a rounding of the customer's credited balance beyond the eighth place.
The wallet-rpc session that proved the deposit is named in the XMR log line
written when the deposit is handed over for crediting ("Transaction … processed
successfully (session N)"); it is not stored on the transaction row. That line
alone does not prove a credit. It is also written when the credit could not be
made at that moment and the deposit was parked for Ecosystem's verification job
to book — "Storing … as pending for verification worker", under the DEPOSIT
tag — or was refused as already credited. The line that records a credit is the
DEPOSIT one: "Deposit … processed and broadcast immediately", or, for a parked
one, "Transaction … fully processed and removed from pending".
Crediting is deduplicated three times: an in-memory walletId-txid key with a
thirty-minute expiry; a check for any existing DEPOSIT row with the same
trxId and the same walletId, whatever its status — so a deposit an operator
rejected, or one reversed after a wrong credit, is never credited again; and the
ledger credit's own idempotency key, eco_deposit_<txid>_<walletId>. The
pairing with walletId is what makes Monero's pay-to-many work — one
transaction can pay several of your customers at once, and each wallet must
credit it independently.
Monitor lifecycle
The monitor is not tied to the WebSocket session, and this surprises people.
A monitor stops when one of three things happens:
- Its lists are empty. Three consecutive checks whose
inandpendinglists are both empty, and the wallet has been monitored for at least ten minutes. Both conditions are required; the ten-minute floor exists because a wallet that has not finished syncing legitimately reports nothing. Because theinlist is the wallet's whole incoming history, this stop only ever ends the monitor of a wallet that has never received anything. A wallet with any earlier deposit — credited long ago, or a coinbase output — never returns an empty list, and ends one of the two ways below. - It reaches the check cap with nothing in flight. 120 checks since the customer last opened the deposit page. With a check every five seconds plus the time each session takes, that is somewhere past ten minutes on an idle wallet RPC and longer on a busy one.
- It hits the wall-clock cap. Ninety minutes, unconditionally.
The check cap only applies when nothing is in flight. While a deposit is visible and still confirming, or confirmed but still locked, the monitor keeps running past it until the deposit is credited or the ninety-minute ceiling — because a monitor that stopped at the cap would leave a transaction to confirm with nobody watching, and only the background scanner could still credit it.
A round the session machinery refused — the wallet RPC busy past the two-minute wait, Redis unreachable, a lost lock, an identity or ownership refusal — says nothing about the wallet. It is not counted as a retry or as an empty check (an identity or ownership refusal also resets the run of empty checks), though the ninety-minute cap still applies. A wallet whose row has no recorded XMR address stops being monitored at once, since no later round could prove its file either.
Closing the deposit page does not stop a Monero monitor. Other chains implement a polling stop that the WebSocket teardown calls; the Monero monitor does not, so it runs its own course. That is the correct behaviour here: the user usually closes the tab long before six confirmations.
Background staleness refresh
Separately from any deposit, the addon keeps dormant wallets from drifting too far behind the chain. It picks the single most stale XMR wallet with a positive balance and a recorded address, round-robin, and refreshes it at low priority with a ten-minute budget. "Stale" means six hours since its last refresh.
It runs only where the scheduler runs — the cron process of the default deployment, or the single process of an inline one. Each process says which when its Monero monitoring loop first runs: "Dormant-wallet background refresh runs in this process" or "…is left to the cron process (CRON_MODE=off here)". Even there, a refresh starts only when:
- that process's monitoring loop is idle — no monitored wallets, nothing queued, no daemon back-off;
- no wallet is being live-monitored in any process (the
xmr:monitoring-activemarker), checked again when the session starts; - at least three minutes have passed since the last refresh started or finished — or, for the first one, since the loop first came to this step idle;
- the wallet RPC is free right now. A refresh never waits for the lock; if it is busy, the next attempt comes thirty seconds later.
The loop does not exist until that process has used Monero. The cron process never registers a live deposit monitor, so its loop starts only when its Monero service is built — the first time something in the process uses the chain — and only if the chain is active at that moment. In the cron process that first use is whichever of these comes first:
- An XMR withdrawal the cron process runs. It picks up
PENDINGrows three ways: the sweep it makes once at boot, which takes every waiting row whatever its age; the five-minute watchdog; and a thirty-minute job that runs the same pass as the watchdog. So if an XMR withdrawal is waiting when the cron process restarts, the service is built straight away. - The deposit scanner sweeping an XMR address on its active list — a
customer's, for 72 hours after they last opened the XMR deposit page or their
XMR wallet, or the Convert house's on an install with Instant Convert. This
needs the scanner: with
ECOSYSTEM_BACKGROUND_SCAN="false"no address is registered and nothing is swept. - Pool backing reading or sending XMR.
So after a restart the first dormant refresh can come much later than three minutes, and on a quiet install — no XMR address on the scanner's list, nothing queued, no pool backing — it does not run at all. The "Dormant-wallet background refresh runs in this process" line in the cron log is the sign that it is armed.
Opening the XMR deposit page once puts an address on the scanner's list, and the scanner's next sweep of it builds the service — provided the scanner is on, and the chain is active in the cron process at that moment. If the chain was inactive there — the wallet RPC was down when the sweep came, say — the service is built without the loop, and only a restart of the cron process starts it.
The point is latency at the moment it matters. A withdrawal must refresh the customer's wallet file before it can spend from it, and that refresh has to catch up on every block since the file was last synced — which is why a withdrawal on a wallet this process has not refreshed in six hours is given fifteen minutes for it. Without the background refresh, the first withdrawal from a long-dormant wallet would do all of that catching up with a customer watching.
Know its limits before you rely on it:
- It is slow by design. One refresh, then at least three minutes before the next one starts, counted from when the previous one ended. That is at most twenty wallets an hour — about 120 in the six hours a refresh counts as fresh — and fewer when refreshes are long. An install with more funded XMR wallets than that cannot keep them all within six hours; the loop simply keeps taking the stalest one.
- A customer on the deposit page stops it. While any wallet is being live-monitored it does not start, and a monitor runs for ten minutes or more after the page was opened. On a busy install it gets few turns.
- Its memory is per process and does not survive a restart. "Refreshed in the last six hours" is tracked in memory by the process that did the refresh, so after a restart every wallet counts as never refreshed, and the first refresh waits at least three minutes from the moment the loop exists (see above).
- It shortens the sync, not the budget of a first attempt. In the default two-process deployment a withdrawal's first attempt usually runs in the web process, which counts only its own refreshes — a live deposit check or an earlier withdrawal of that wallet — so it usually opens with the fifteen-minute budget whatever the cron process has done. What the background refresh changes is how many blocks that sync has to cover.
Operating notes
- Wallet count is a real capacity number. Every customer wallet is a file and every check is an exclusive session on the one wallet RPC, whichever process runs it. A few thousand wallets is fine; the serialisation, not the disk, is what eventually binds.
- Deposits stop the moment the daemon does. The wallet RPC can be perfectly
healthy and still return nothing, because it cannot sync. Monitor
monerod, not just the wallet RPC. What deposits depend on is the wallet RPC's own connection to the daemon, through its--daemon-address: a deposit check sends nothing tomoneroditself. The backend's connection to the daemon prices and checks withdrawals, so if only that path is broken, deposits keep crediting while new withdrawals are refused. - Deposits also stop while Redis is down. No session starts without the lock.
XMRis the log tag. Every line the service emits carries it, in the web process's log and the cron process's. See Troubleshooting for what to grep for.