Writing(02) · Case study
A ledger thatrefuses to lie
What building fiat-crypto payment rails at Sendcoins taught me about money in databases. The database enforces the rules, the application just asks nicely.
Sendcoins moves money between Nigerian naira and crypto. A user gets a virtual bank account, deposits naira, buys stablecoins, sends them across chains, or cashes back out. Every one of those steps is an opportunity to create money out of thin air: a payment provider retries a webhook, a user double-taps a button, two requests race, a process dies between debit and credit. In fintech, the default outcome of ordinary distributed-systems noise is that somebody gets paid twice.
The rule I built the backend around: the application is not trusted to be careful, the database is made incapable of being careless.
Idempotency is a contract, not a middleware
Every money-moving operation carries an idempotency key backed by a unique constraint in Postgres, and the code treats a duplicate as a first-class answer, not an error. The withdrawal flow inserts with the key, catches the database’s unique-violation error code, and returns the original withdrawal with a duplicate flag, so a caller that retried gets back exactly what happened the first time. The credit path checks the key inside the transaction before touching a balance and answers 409 if it’s seen it before. Derived operations get derived keys: a buy generates its own sub-keys for the debit leg and the pending leg, so even a half-completed flow can be replayed safely.
The subtle part is checking before any state changes. An idempotency check after the first write is a bug with a delay on it.
Locks before balances, always
Every credit and debit starts with SELECT ... FOR UPDATE on the wallet row, and the debit re-reads the balance under the lock before deciding whether funds are sufficient. Reading the balance first and locking second is the classic race: two withdrawals both see enough money, both proceed, and the account goes negative. The stored balances themselves carry a database CHECK constraint, actual equals available plus locked, and none may go below zero, so even a bug that slips past the locks hits a wall the application can’t argue with.
Double-entry, enforced by a trigger
The naira side keeps a true double-entry journal: every transaction writes paired credit and debit rows, and every transaction row snapshots the balance before and after. The enforcement is my favorite detail: a deferred constraint trigger runs at commit time and raises if the entries don’t balance. Not a code review convention, not a linter rule, an actual database object that makes an unbalanced ledger unrepresentable. Auditing a dispute stops being archaeology, because the journal can’t have gaps.
The gas race and the advisory lock
The best bug taught the best lesson. On Tron, sends from a user wallet consume shared gas, and two concurrent sends from the same wallet would race over it and die on-chain with OUT_OF_ENERGY reverts. The fix was a Postgres advisory lock keyed on a hash of the wallet address: a send tries to take the lock, polls briefly, and if another send holds it, fails fast with a clear SEND_IN_PROGRESS error instead of hanging or half-sending. One database primitive serialized money movement across processes, no Redis, no distributed lock service, no new infrastructure.
The rollback that rolled back nothing
One post-mortem lives permanently in a code comment. The original transaction helper called the connection pool per query, which means BEGIN, the writes, and ROLLBACK could each land on different connections. The rollback executed successfully and rolled back nothing. The fix is boring and absolute: check out one client, run the whole transaction on it, always release it. If you use a pool and you haven’t explicitly verified this, go check now. It fails silently and only under load.
Reconciliation that never touches money
Trust, then verify: scheduled jobs compare what the database says users are owed against what actually sits in custody on-chain and in the naira float, write a report, and alert on any shortfall past a small tolerance. The crons are deliberately read-only, they surface problems and never fix them, because a reconciliation job with write access is just a new way to move money wrongly. Settling a mismatch stays a deliberate human step.
What I’d tell you to copy
Put the invariants in the database: unique keys for idempotency, CHECK constraints on balances, a trigger on the ledger. Treat duplicates as answers, not errors. Lock before you read any balance you’re about to change. And keep one read-only job whose whole purpose is to catch the day everything else failed. None of this is exotic, which is the point. Money bugs don’t come from missing genius, they come from missing discipline.
Building payment rails, billing, or anything a retry could double-charge? This is my specialty.
See services