The player paid after the deposit expired. Chain truth wins.
Almost every crypto gateway issues a deposit address with an expiry: a window, typically 15 to 60 minutes, in which the player is expected to pay. It is a sensible piece of bookkeeping. It is also a fiction the blockchain has never agreed to.
Players walk away mid-payment and come back later. Exchange withdrawals sit in a manual review queue for an hour. Someone copies the address, gets distracted, and sends the funds the next morning. The transfer confirms whenever it confirms, and no expiry timestamp in your database has any effect on it.
How the money disappears
The failure is subtle because nothing errors. A watcher typically polls the addresses of deposits that are still pending. When a deposit passes its TTL it is marked expired — and drops out of that query. The address stops being watched.
So the late payment arrives at an address that belongs to the operator, derived from the operator’s own key, and simply nobody looks at it again. The funds are not stolen or stuck; they are sitting in a wallet the operator controls, unaccounted for and uncredited. The player is certain they paid, and they are right. Support has nothing to go on except a transaction hash and a block explorer.
We shipped exactly this bug. The README promised that chain truth wins and funds are never blocked, and for late payments the watcher quietly did the opposite. It was not caught by a code review; it was caught by writing a test that paid a deposit one minute after it expired and asserting the player got credited. The test failed, which is how we learned the promise was only true in the paragraph.
The fix is to stop trusting your own bookkeeping
The correction is a reconciler that re-checks issued addresses regardless of what the deposits table thinks their status is. It runs on a slow loop behind the watcher, looks back over a week of addresses, and settles whatever the chain says is there.
If a deposit was still owed money — pending or expired makes no difference — the payment credits it, however late. The credit is flagged, and the webhook carries a late flag, because that distinction matters to you: your backend may have already told the player the window closed, and “credit this player now” lands differently when you know it is a straggler rather than a normal deposit.
The second payment problem
There is a related case that is easier to get wrong. A player pays, gets credited, and then pays again into the same address — a double-send, a nervous top-up, a copy-pasted address reused a day later. The deposit that owned that address is settled, so there is nothing to credit.
Auto-crediting it is tempting, and we decided against it. The address does tell us whose money it is, so the funds are clearly attributable — but your backend does not expect a credit that belongs to no open deposit, and silently moving a player balance on that basis is the kind of thing that is very hard to unwind. Instead the payment lands in an unmatched queue with the player, the amount and the transaction, a webhook goes out, and a human decides: credit it, or reject it with a reason. The default is visibility, not a guess.
Idempotency is what makes reconciliation safe
A job that re-examines the same addresses repeatedly is only safe if crediting twice is impossible. Both the watcher and the reconciler write through the same ledger, and every credit carries a unique key of network, transaction hash and address, enforced by a database constraint inside a transaction. The reconciler can run every five minutes over the same week of history forever and the ledger will not move. That constraint is what allows the safety net to exist at all.
What to check
Ask your gateway what happens to a payment that arrives after the deposit window closes, and to a second payment into an address that already settled. There are two acceptable answers: it is credited, or it is queued for you to decide. “It expired” is not one of them — the chain does not know what an expiry is, and the money is still yours.