Money That Moves Between Rails: A Multi-Currency Wallet Ledger
May 18, 2023 · 6 min read
A wallet that holds local fiat alongside several cryptocurrencies looks like a blockchain project. It mostly is not. The blockchain calls, generate an address, read a balance, broadcast a transaction, are the well-documented, easy part. The part that will hurt you is the bookkeeping: under concurrency, retries, and flaky external providers, never losing a cent and never inventing one. That is an accounting problem, and accounting solved it centuries ago with double-entry. Your job is mostly to use that solution instead of reinventing a worse one under deadline pressure.
The non-negotiables
- Double-entry ledger. Every movement is balanced debits and credits, and balances are derived by summing entries, never stored and mutated directly. A balance you can edit is a balance you can corrupt; a balance you can only derive is auditable by construction, and it can never silently disagree with its own history.
- Idempotent operations. Keyed on an intent id, so a retry, and there will be retries, cannot apply the same movement twice.
- Settlement as async jobs. A queue with retries and a dead-letter, so a slow provider or a transient failure does not lose the operation or block the user staring at a spinner.
- Integer minor units. Money is integers in the smallest unit, kobo, cents, satoshis, wei, with precision tracked per currency. Floating point has no place anywhere near a balance, because the first rounding error is the last time the books balance.
Concretely, a single conversion is two balanced entries that either both post or neither does. The transaction is the unit of correctness, not the individual write.
A cross-currency conversion also records the rate used and the timestamp, so the two sides reconcile and you can later answer "what rate did this user actually get, and when?" Support will ask, finance will ask, and at a certain size a regulator will ask, and "we did not store that" is not an answer any of them accept.
External rails reconcile through the ledger
Deposits, on-chain confirmations, and provider webhooks do not get to write balances directly. They post ledger entries like everything else, through the same idempotent, validated path. This single rule is what keeps a flaky chain, a duplicate webhook, or a provider's helpful retry from quietly desyncing your books. And because those external systems are the real source of truth for what happened on their side, you reconcile continuously, comparing your ledger against the chain and the providers, and you alert on any drift rather than discovering it during a customer dispute. A discrepancy is a bug to find, not a number to overwrite.
Fees, dust, and the rounding problem
Real money has rough edges that a clean balance model likes to ignore, and each one is a place to lose or invent value if you are careless. Network fees are the obvious one: when a user withdraws crypto, someone pays the chain fee, and whether that is the user, the platform, or a split has to be an explicit ledger entry, not a silent deduction that makes the numbers almost balance. Dust is the next one: tiny residual amounts below what is economically movable accumulate, and you decide deliberately whether to sweep, ignore, or absorb them rather than letting them quietly break reconciliation. And rounding is the quiet killer in conversions, because converting between currencies with different precisions forces a rounding decision on every transaction, and if you do not define who eats the fraction of a unit, consistently and in the ledger, your books will drift by a cent here and a satoshi there until a month-end total refuses to reconcile and nobody can say why. The fix is boring and non-negotiable: every fee, every rounding remainder, every swept dust amount is its own ledger entry, so the sum always ties out exactly.
The reconciliation runbook
Reconciliation is not a feature you build once and forget, it is an operational practice you run forever, so it helps to make it concrete. On a schedule, you pull the authoritative state from each external rail, the on-chain balances, the provider's reported ledger, the bank's statement, and you compare it line by line against what your ledger says should be true. Matches are ignored. Mismatches are surfaced loudly, classified, and chased, because a mismatch is one of three things: a timing difference that will resolve on the next run, a known edge you have not modelled yet, or a genuine bug that is silently losing or creating money right now. The discipline is that you never fix a mismatch by editing a balance, because the balance is derived and there is nothing to edit; you find the missing or wrong ledger entry and you correct the entry, which leaves an auditable trail of exactly what happened and why.
Why this beats a balances table
It is fair to ask why not just keep a balances table and add and subtract from it, since that is so much simpler. The answer is that a mutable balance has no memory and no proof. When it is wrong, and under concurrency and retries it eventually will be, you have no way to know how it got wrong, when, or by how much, and your only recovery is a guess. A ledger is the opposite: the balance is a consequence of an immutable history, so any number can be explained by replaying the entries that produced it, and any error is a wrong or missing entry you can find and correct without ever touching the balance itself. That property, the ability to answer "why is this number what it is" with certainty, is the entire reason finance has used double-entry for centuries, and it is exactly the property you want the first time real money is on the line.
The tradeoffs
Double-entry feels like overkill right up until your first dispute, audit, or "why is this balance wrong?" incident, at which point it is the only thing that lets you answer with certainty instead of a shrug and a manual database query. Async settlement means balances are eventually consistent, so you surface pending states honestly rather than pretending money has arrived before it has. It is more machinery than "increment a number," and that machinery is precisely what you are being paid to get right when the numbers are someone's actual money.
Enjoyed this? Let me know