Every stablecoin business keeps at least three records of the same money: its own ledger, the balances on-chain, and the balances a provider or custodian reports. On a good day they agree to the last base unit. On a bad day they drift apart by a few cents, then a few hundred dollars, and nobody notices until a customer withdrawal fails or an auditor asks a question nobody can answer.
Drift is rarely the bug itself. It is the symptom that makes every other bug visible: a duplicate payout, a missed deposit, a fee booked twice, a quiet transfer out of the hot wallet. This guide covers how to structure a ledger so it can be reconciled, how to take comparable snapshots of three systems that never stand still, and what to do when they disagree. It expands on No. 09 in the failure modes guide.
No. 01Where drift comes from
In the codebases I review, unexplained differences almost always trace back to one of these:
- Credited, then reorged. A deposit was booked at first sight and the block was later replaced. See the finality guide.
- Sent twice, booked once. A payout retry signed a second transaction, and the ledger only knows about the first. See payout idempotency.
- Gas never booked. Every outgoing transfer costs native token, and the ledger only tracks the stablecoin.
- Rounding at the edges. A float slipped into a fee calculation. See decimals and rounding.
- Funds nobody asked for. Dust, mis-sent deposits on an unmonitored chain, or tokens sent to a sweep address directly.
- Manual fixes. Someone ran an
UPDATE balances SET …during an incident and never wrote down why. - Theft. A key leaked and funds left a wallet without passing through the payout system.
The last one is the reason reconciliation is a security control and not just a finance task. Without it, theft from a hot wallet looks exactly like every other small discrepancy.
No. 02A ledger you can reconcile
Most drift is undiagnosable because the ledger is a single mutable balance column per user. You can see that the number is wrong, but not which event made it wrong. Three properties fix that:
- Double-entry. Every movement is a transaction with entries that sum to zero per asset. Money never appears or disappears; it moves between accounts.
- Append-only. Entries are never updated or deleted. A mistake is corrected with a reversing transaction that references the original.
- Linked to evidence. Every entry that touches the chain carries the chain ID, transaction hash and log index. Every entry that touches a provider carries its event or transfer ID.
Accounts are scoped to the asset and the place it lives, so a USDC balance on Base is never mixed with USDC on Solana:
CREATE TABLE ledger_accounts (
id bigserial PRIMARY KEY,
code text NOT NULL UNIQUE, -- e.g. 'wallet:base:usdc:0x9f…:available'
kind text NOT NULL, -- wallet | customer | fees | gas | suspense | provider
chain_id text NOT NULL, -- 'eip155:8453', 'solana:mainnet'
asset text NOT NULL, -- pinned contract or mint address, never a symbol
address text, -- on-chain address for wallet accounts
bucket text NOT NULL -- pending | available
);
CREATE TABLE ledger_transactions (
id bigserial PRIMARY KEY,
kind text NOT NULL, -- deposit | payout | fee | sweep | reversal
reverses bigint REFERENCES ledger_transactions(id),
chain_id text,
tx_hash text,
log_index integer, -- one tx can carry several transfers
provider_ref text, -- provider event or transfer ID
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (chain_id, tx_hash, log_index),
UNIQUE (provider_ref)
);
CREATE TABLE ledger_entries (
id bigserial PRIMARY KEY,
txn_id bigint NOT NULL REFERENCES ledger_transactions(id),
account_id bigint NOT NULL REFERENCES ledger_accounts(id),
amount numeric(78,0) NOT NULL -- base units, signed; fits any uint256
);
-- No UPDATE or DELETE for the application role.
REVOKE UPDATE, DELETE ON ledger_entries, ledger_transactions FROM app_role;
The unique constraint on (chain_id, tx_hash, log_index) is quietly one of the most valuable lines in the schema: it makes a deposit watcher that processes the same log twice fail loudly instead of crediting twice. Enforce the zero-sum rule in the same database transaction that writes the entries, with a check before commit or a deferred constraint trigger, so an unbalanced transaction can never land.
No. 03Pending and available are different accounts
A deposit seen in a recent block is not yet money. Book it into a pending account on first sight, and move it to available with a second transaction once the block is final. If the block is reorged out, reverse the pending entry. Customers can see pending funds; nothing can spend them.
Keeping these as separate accounts, rather than a status flag on one row, means the reconciliation compares like with like: on-chain balance at a finalized block should equal the sum of available plus pending entries that are already final, and nothing else.
No. 04Snapshot all three sides at the same point
Comparing “the ledger now” with “the chain now” produces false breaks every run, because transfers land between the two queries. Pick a cutoff, a specific finalized block per chain, and read every side as of that point.
On EVM chains, read balanceOf at that block number rather than at latest:
// ethers v6. USDC address pinned per chain in reviewed config.
const erc20 = new ethers.Contract(USDC[chainId], ["function balanceOf(address) view returns (uint256)"], provider);
const cutoff = await provider.getBlock("finalized");
const onChain = {};
for (const address of walletAddresses) {
onChain[address] = await erc20.balanceOf(address, { blockTag: cutoff.number });
}
// Store cutoff.number and cutoff.hash with the snapshot so the run is reproducible.
Run this promptly after choosing the cutoff. Full nodes generally keep only recent state, so a query for an older block can fail on a non-archive node or a basic RPC plan. If you need historical replays, use an archive endpoint.
On Solana there is no block parameter for balance queries. Read token accounts with finalized commitment and record the slot the RPC returns in its response context; that slot is your cutoff, and the ledger side is filtered to transactions in slots up to it. Read the balance of each token account for the pinned USDC mint, not a wallet-level total, since one owner can hold several token accounts.
The ledger side is a query over entries whose linked transaction falls at or before the cutoff block:
SELECT a.address, sum(e.amount) AS ledger_balance
FROM ledger_entries e
JOIN ledger_accounts a ON a.id = e.account_id
JOIN ledger_transactions t ON t.id = e.txn_id
JOIN chain_txs c ON c.chain_id = t.chain_id AND c.tx_hash = t.tx_hash
WHERE a.kind = 'wallet'
AND a.chain_id = $1 AND a.asset = $2
AND c.block_number <= $3 -- the cutoff block
GROUP BY a.address;
Provider and custodian balances are the hardest side, because most APIs only return the balance now. Snapshot them as close to the cutoff as you can, and pull the provider’s transaction list for the window so in-flight movements can be explained rather than counted as breaks.
No. 05Match transactions, not just totals
Totals that agree can hide two errors that cancel out: a missed deposit and a double-booked payout of the same size. Matching at transaction level finds both. Index every token transfer touching your addresses into a table (from your own indexer or a data provider), then full-outer-join it against the ledger on the chain reference:
SELECT
coalesce(o.tx_hash, t.tx_hash) AS tx_hash,
coalesce(o.log_index, t.log_index) AS log_index,
o.amount AS onchain_amount,
l.amount AS ledger_amount,
CASE
WHEN t.id IS NULL THEN 'missing_in_ledger'
WHEN o.tx_hash IS NULL THEN 'missing_on_chain'
WHEN o.amount <> l.amount THEN 'amount_mismatch'
END AS break_type
FROM onchain_transfers o
FULL OUTER JOIN ledger_transactions t
ON t.chain_id = o.chain_id AND t.tx_hash = o.tx_hash AND t.log_index = o.log_index
LEFT JOIN LATERAL (
SELECT sum(e.amount) AS amount
FROM ledger_entries e JOIN ledger_accounts a ON a.id = e.account_id
WHERE e.txn_id = t.id AND a.kind = 'wallet'
) l ON true
WHERE (o.chain_id = $1 OR t.chain_id = $1)
AND (o.block_number <= $2 OR o.block_number IS NULL)
AND (t.id IS NULL OR o.tx_hash IS NULL OR o.amount <> l.amount);
Sign conventions matter here: store on-chain amounts signed from the wallet’s point of view (inflows positive, outflows negative) so they compare directly with wallet entries. Do the same matching against the provider’s transaction list using its transfer IDs.
No. 06Book gas, or the native balance never reconciles
Stablecoin transfers are paid for in the chain’s native token: ETH on Ethereum and most L2s, SOL on Solana. If the ledger tracks only USDC, the native balance of every payout wallet drifts down with each transaction and the reconciliation for it is meaningless.
- Give each wallet a native-token account and a
gasexpense account, and book every outgoing transaction’s fee from the receipt:gasUsed × effectiveGasPriceon EVM chains,meta.feeon Solana. - On OP Stack chains such as Base, the total cost also includes an L1 data fee, which the receipt reports separately (
l1Fee). Booking only the L2 execution fee leaves a small, steady drift. - On Solana, creating a token account for a recipient locks a rent-exempt deposit in SOL. Book it as its own entry, not as a fee.
- Failed transactions still pay gas. They must produce a fee entry with no stablecoin movement.
Also check whether the token can charge a transfer fee. USDT’s Ethereum contract has fee parameters that are currently set to zero; if they ever change, the amount received will differ from the amount sent, and transaction-level matching is what will tell you first.
No. 07Classify every break and give it an owner
For a stablecoin ledger the acceptable difference is zero base units. Anything else is a break, and every break gets a type, an owner and a deadline:
- Timing. The transfer is after the cutoff on one side and before it on the other. It must clear on the next run; if it doesn’t, it is not timing.
- Missing in ledger. Funds arrived or left on-chain with no ledger entry. Inflows go to a suspense account pending investigation. An unexplained outflow is a security incident until proven otherwise.
- Missing on chain. The ledger records a movement the chain never saw: often a reorged deposit or a payout that never broadcast.
- Amount mismatch. Usually decimals, fees or rounding.
- Provider mismatch. Your ledger and the chain agree but the provider disagrees, or the reverse.
Breaks are resolved with ledger transactions, never by editing rows. A correction is a new transaction of kind reversal or adjustment, with a reference to the break, the reason, and the approver. Require a second person to approve any adjustment above a small threshold, and alert when a break stays open past its deadline. The reconciliation job is not the control. Someone being paged when it fails is.
No. 08Don’t wait for the daily run
A daily reconciliation tells you about a drained hot wallet up to 24 hours late. Add continuous checks alongside it:
- Watch every
Transferlog from your wallets in real time and match each one against a payout intent. Any outgoing transfer the payout system did not create pages someone immediately. - Watch native-token outflows the same way: an attacker with the key will often move ETH or SOL too.
- Compare each wallet’s on-chain balance with the ledger every few minutes at the
safeorfinalizedhead, and alert on unexplained changes rather than on totals at end of day. - Alert when expected activity stops: a deposit watcher that falls behind looks exactly like a quiet day.
No. 09What auditors ask for
Whether it is a financial audit, a SOC 2 assessment or a partner’s due diligence, the requests tend to be the same, and a well-built reconciliation answers them directly:
- Evidence that each run happened: the cutoff block per chain, the snapshot values, the result, and the code version that produced it.
- A log of every break, how it was classified, who resolved it and when.
- The list of manual adjustments, each with a reason and a second approver.
- Proof that the ledger cannot be edited in place: database permissions, not a policy document.
- The ability to reproduce any past balance from entries alone, and to trace any entry to a transaction hash or provider record.
If you can produce those on demand, a large share of the compliance work is already done.
Test planTen tests for your reconciliation
- Inserting the same deposit log twice fails on the unique constraint and books nothing.
- An
UPDATEorDELETEon ledger entries from the application role is refused by the database. - A transaction whose entries don’t sum to zero is rejected before commit.
- A testnet deposit reorged out after first sight ends with a reversing entry and a reconciled balance.
- A transfer that lands between the ledger query and the chain query does not produce a break.
- A transfer sent from a payout wallet outside the payout system pages someone within minutes.
- A missed deposit and a duplicate payout of the same amount produce two breaks, not a matching total.
- The native-token balance of a payout wallet reconciles after a run of payouts, including one failed transaction.
- An injected one-base-unit difference opens a break with an owner and alerts when it ages past its deadline.
- Any balance from last month can be reproduced from entries and traced to on-chain or provider records.
Want a second pair of eyes?
I run this review on your code, before launch.
Fixed scope, fixed fee, a written report with fixes, and a re-test once you’ve shipped them. Most reviews take five to ten working days.