
Part 1 of this "ledger series" ended in a simpler world, where every movement settled as soon as it was recorded. The thing now is real money do not behave that way. Tobi for example is one of your users, he has 100 USDC in your app, and another 50 USDC is sitting on a deposit that hasn't been confirmed, and a withdrawal of 30 USDC is on its way to an external wallet. What should his balance show at the time, and how much of it can he actually spend?
Depending on what the app counts it can show 100, 120 or 150, and the honest answer to how much he can spend is 70. That interval between what is recorded, what is expected and what is spendable at the time is what this part II is about.
Nothing about the double entry model changes here. Every movement still balances, entries are still written once and never touched. What changes is that money now spends time in different states. A card payment takes days to settle, a crypto deposit sits unconfirmed until block confirmations gets to a certain defined number, a payout is in flight with a provider that might possibly fail no one knows.
Part 1's single balance can only describe the money that arrived. It has no way to describe money that is not yet settled or in transit/flight, so systems keeping one number end up either hiding money the user owns or surrendering over money that isn't in custody yet. The system has to record money in a pending state too.
A ledger that models in transit money answers with three numbers, and all of them are still just sums, over the entries and the deposits still waiting to post.
Posted: This is the sum of every settled movement across all of Tobi's accounts, his wallet plus any accounts holding money reserved for his withdrawals, the final facts. Tobi's posted balance is 100 USDC.
Pending: This is the one that get's confusing most times, because "pending balance" gets used to mean two different things. One is the pending activity alone, +50 USDC incoming and -30 USDC outgoing, 80 USDC in flight, a net +20. The other is posted plus that activity, 100 + 50 - 30 = 120 USDC, what the balance will most likely settle at. These are two different questions, never mix them in a single endpoint.
Available: This is what Tobi can actually spend right now. The posted value minus outgoing values, 100 - 30 = 70 USDC. Incoming money that hasn't settled is not available, the 50 USDC still unconfirmed on chain doesn't exist for spending under this wallet until it settles.
Available is one number here because this wallet runs one policy. Many systems split it, what Tobi can spend and what he can withdraw end up as two different numbers. A card deposit can be spendable inside the app straight away and stay locked for payments for days, because otherwise someone deposits with a stolen card, pulls it out as crypto the same moment, and you're the one left holding the chargeback. Some companies go the other way and grant temporary credit, money made available before it settles based on some parameters, that's how instant deposits work, and in the US, Regulation CC even sets the schedules banks have to follow for when deposited funds become available. Merchant balances then carry cash buffers and risk holds on top of all that.
Temporary credit has a price though. You credit Tobi the moment the deposit shows up, DR deposits_in_transit 50 / CR tobi:wallet 50, he spends it, the deposit fails for any odd reason, and now his wallet is negative. So the ledger has to allow negative balances on specific accounts by design, and track it as a receivable money Tobi now owes you. And that's where your fraud losses and credit risk come from, so you decide up front which account types are allowed to go negative. Some ledger systems like the one we built for example has a flag for exactly this, debits_must_not_exceed_credits, set it on an account and any transfer that would push its debits past its credits gets rejected.
Now, take Tobi's movements one at a time. Just like in Part 1, each one is still recorded as a balanced transaction.
His 50 USDC deposit gets detected with one confirmation. The coins are on chain but not final as you know, the network can still throw away the block holding them and swap in a different one, which is called a "reorg", this is money you can see but don't hold yet. Nothing gets posted to the ledger at this point. The deposit gets its own append only history instead rather than a posting, every time something about it changes, a new confirmation, a new status, that's a new row, and Tobi's pending balance reads the 50 from there. No entry touches custody or his wallet. Custody is what gets reconciled against the chain's finalized balance, which is exactly what auditors and proof of reserve systems check, standardly ofc.
Balances now: posted 100, pending activity +50, available 100.
The deposit only posts once the chain calls it final, and once your custody provider's own record of that exact deposit agrees. Check it deposit by deposit. If you only compare against the provider's total balance, another deposit landing at the same time can make the total look right while this 50 is still missing. Then it's one transaction:
DR custody 50
CR tobi:wallet 50Now, in the same commit the deposit's history gets its credited row and the 50 moves from pending into available. Balances now: posted 150, pending activity 0, available 150. Nothing was edited, the ledger simply heard just now about this deposit, when it was already final.
What "final" means depends on the chain. Twelve confirmations is a proof of work thing. On Ethereum proof of stake the real signal is the finalized checkpoint, two to three epochs, ~15 minutes on a normal day. Solana has its own commitment levels, and since its Alpenglow upgrade went live in October 2026, confirmed and finalized mean the same thing. On a rollup like Base or OP, the sequencer confirming your transaction is only the first step, L1 finality comes ~20 minutes later. Finality ends up being a floor you set per chain, and every deposit should record which floor settled it, so the day a chain misbehaves you know exactly which deposits got credited under which rule. And if the provider calls a deposit complete while it's still below your floor, you hold it for review and credit nothing. In a case where you use a system like Fireblocks you simply set it up in your dashboard.
Now, a reorg before finality is the simple case, and the deposit hasn't failed. The transaction usually goes back to the mempool and lands again in a later block, so the deposit just drops back to unconfirmed and its confirmation count starts over. Mark it failed at that point and one of two things happens when it lands again, your system ignores it and Tobi never gets credited while custody sits on coins you owe nobody, or it shows up looking like a brand new deposit and Tobi gets credited twice. A deposit only fails once it's provably gone, on Ethereum for example when another transaction from the same sender used that nonce and that one is final, and even then there's nothing to reverse because the ledger never saw it.
That second case is why the key for a deposit matters a lot. It's possible for one transaction to carry several USDC transfers, so the key is the chain, the transaction hash and where the transfer sits inside that transaction. On Ethereum that's its place in the transaction's receipt. Don't reach for logIndex in this case, it counts across the whole block, so it changes the moment a transaction from a thrown away block lands in a different one. And the unique constraint that makes sure each deposit is credited once sits on the ledger transaction that posts it. Put it on the history and it rejects the second confirmation row, since every row there carries the same key.
Back to Tobi, his 50 went through. Now he asks for a 30 USDC withdrawal. Before any provider is called, the money is reserved:
DR tobi:wallet 30
CR tobi:withdrawals 30Balances now: posted 150, pending activity -30, available 120. Posted is still 150 because it reads across all of Tobi's accounts, the wallet dropped to 120 and the withdrawals account picked up the 30. The reservation is a real double entry, which is why availability drops before the withdrawal posts, the 30 is already spoken for even though it hasn't left the building.
You might have noticed the deposit stayed off the ledger until it was final, while the withdrawal went on the ledger before anything left. It comes down to what each one does to available. The hold takes away from what Tobi can spend, so it has to commit together with the balance check, or two requests can both spend the same 30. The pending deposit adds nothing he can spend yet, so it can wait outside until it's final. Provisional credit is the one exception, that's why the deposit there posts straight away.
The provider delivers, and the payout completes:
DR tobi:withdrawals 30
CR custody 30Balances now: posted 120, pending activity 0, available 120. The completion only clears the reservation, custody pays the rail and the wallet stays untouched. If posting deducted availability again, Tobi would watch 150 become 90 while paying 30.
Now, I kept that withdrawal clean on purpose, it reserved 30 and settled 30. Real ones rarely do. Gas isn't known until the transaction is mined, providers take their own fees, and gas for a USDC transfer is paid in ETH, a different asset. So the amount you hold and the amount that finally settles almost never match, you reserve an upper bound, the 30 plus the most the fee could be, say 2:
DR tobi:wallet 32
CR tobi:withdrawals 32Then settle the actual amounts and release the rest, in one transaction:
DR tobi:withdrawals 32
CR custody 30.00
CR revenue:fees 0.40
CR tobi:wallet 1.60Available dropped by 32 while it was in flight and lands at 119.60, and the withdrawals account still nets to zero. The gas the network actually charged is your expense, paid from your own ETH in a separate movement, DR expense:gas / CR treasury:eth, and it balances on its own because Part 1's check runs per asset. The 0.40 is your revenue now, so it gets swept out of customer custody too, DR treasury:usdc 0.40 / CR custody 0.40. You keep custody for customer money only. If you pay gas out of it, every withdrawal eats a little of everyone else's money, and the first place that will shows up is your reserves cron, what you hold per asset against what you owe users, coming back short.
Card rails make this harder still:
Three ways this goes, and only two of them are failures.
A simple error: the pending withdrawal is rejected before it posts, maybe the address fails screening. The reservation is released:
DR tobi:withdrawals 30
CR tobi:wallet 30Available comes back to 150, posted never moved because the money stayed.
The expensive one: the payout posted, the chain confirmed it, and days later the exchange it went to sends it back. That return is a new financial movement of its own, and on chain it's just another incoming transfer, so it goes down the same path as any deposit, its own history, your finality floor, matched to the exact payout it's returning, and only then does it post. Post whatever actually arrived too, the exchange sending it back can keep its own fee, so 30 left and maybe 29.50 comes back to you:
DR custody 29.50
CR tobi:wallet 29.50The original payout stays in history and so does the return.
And then there is the third way, the one that gets most people in a loss: the provider timed out and nobody knows if the payout landed. You cannot treat an unknown status as failure. Treat it as failed, release the 30, it lands on chain anyway, and just like that you paid twice, congratulations!
The actual standard is an unknown outcome stays pending until the provider answers.
Some providers never answer though depends on how their system is built. So unknown can't sit in pending forever either, it walks down a ladder, and each step either resolves it with a ledger transaction or hands it to the next one:
And to even know something is stuck, a clearing account can't stay one number. Say Tobi sends two 30 withdrawals, one settles and the other never does. tobi:withdrawals shows 30, and since both were 30, nothing in that number tells you which one is stuck. So every entry that settles or releases something points at the exact entry it closes, that's open item matching, borrowed straight from accounts receivable, and an alert fires on anything still open past your rail's SLA.
Looking back at everything Tobi's money did above, no existing entry was ever updated, and neither was a single row of the deposit's history. The state moved because new transactions landed on top of old ones, and that is the second half of this article.
These type of systems are designed to get corrections, an entry with an extra zero, a movement booked to the wrong account. The temptation at 2:45 AM is an UPDATE. You fix data with a correcting txn, never a row update. The correction has to mirror the original with the sides flipped and points back at it, carrying a reason and a source so the record explains itself when looked at:
function reverse(original: Txn, reason: string, source: string): Txn {
return {
id: randomUUID(),
referenceId: original.id,
effectiveAt: original.effectiveAt,
reason,
source,
entries: original.entries.map((e) => ({
accountId: e.accountId,
side: e.side === "DR" ? "CR" : "DR",
amountMinor: e.amountMinor,
assetId: e.assetId,
})),
};
}Every field gets copied by name on purpose, spread the original entry with ...e and the correction ends up with the original's id and timestamps too. The two txn's together net to zero, and the final balance never hides how it happened. Support can replay Tobi's history, the deposit's own rows and then the ledger, and watch deposit, confirmation, reservation, release, in order. Your db backs this up the same way Part 1's rule did, an UPDATE or DELETE against an entries table gets rejected! No second thoughts to this.
One thing reverse() doesn't stop as written is reversing the same transaction twice, run it twice and Tobi gets his money back two times. The reversals of a transaction must never add up to more than the original. A simple unique index can stop a second full reversal, but it can't add anything up, so a full reversal plus a partial one still slips through. The real check reads what has already been reversed and writes the new reversal as one atomic txn, and that is a Part III problem, which we'll talk about.
Part 1 said the stored balance is a cache that must agree with the log. With three balances the stakes go up, because the cache is what approves spending. A balance used to approve a withdrawal has to reflect committed reservations and postings, and it has to move in the same commit that writes the entries, otherwise a read in between sees money that is already promised.
The test from Part 1 still holds, drop every stored number and replay the ledger plus the deposit history, you get the same three balances back, and the pending part can always be checked against a fresh scan of the chain. With the checkpoints from above, the replay starts at the last verified checkpoint instead of the very first entry ever written, and that's what keeps this test workable at scale. And when a stored number and a replayed number disagree, a recon job compares the two and alerts on drift. Then you find out which one is lying. If the entries are right and the cache is wrong, you rebuild the cache from the entries, no new financial movement belongs in that fix. If the entries themselves are wrong, you post a correcting txn like the one above. Either way you investigate before you trust the number again. That alert is not negotiable.
SIDE NOTE: a stored balance is a sum, and sums need room. USDC has 6 decimals on Ethereum and 7 on Stellar, ETH has 18. A signed 64 bit integer tops out around 9.22 × 10¹⁸, in wei that's only ~9.22 ETH, one decent deposit. Postgres bigint is exactly that 64 bit integer, same word as Part 1's TypeScript bigint, very different limit. Use 128 bit amounts, which is eactly what we do, which in Postgres means numeric with a scale of 0, keep bigint rather than number in TypeScript, and store each asset's scale with the asset, 30000000 means nothing until you know it's USDC at 6 decimals.
Now, talking about how the commit itself stays safe under high load, the locking, etc, that's Part III.
There are two clocks on every movement, when it became effective and when your system recorded it. A card settlement can be effective yesterday and arrive today, a provider's webhook can show up hours late. Rebuild yesterday's balance today and it contains movements the system hadn't heard of yesterday. That number is what you know now about yesterday, not what you knew then.
Corrections and returns each have to pick an effective date too. A correction of a booking error carries the original's created at date, because the original was never true, that's why reverse() above copies original.effectiveAt. A return carries today's, because the payout actually did happen and the money really came back later. Once you get these two the wrong way round and every as of balance for those days comes out very wrong, costing you again.
Then there are closed periods. Once a period is closed, statements sent, reports filed, interest accrued, a late entry effective inside it can't change those numbers anymore. You post it in the open period as an out of period adjustment with an effective date pointing back, regular banks call these the value date, when it counts from, and the booking date, when it was posted. And a back valued entry means interest or fees were worked out on the wrong balance, so the recomputation goes in as a new adjusting transaction as well.
The model here is right, we have three balances, append only history, cache in agrees. Now two withdrawals for 80 USDC each arrive a millisecond apart. Both read available 120, both pass the check, both reserve. 160 out of 120, and the history will simply record how you lost it.
Nothing above stops that, and nothing above was wrong. Balances and history describe money, they don't guard the write, two requests can still read the same available and both write inside the same. Making that write atomic, the check and the reservation committing together or not at all, is what Part III is about, Transactions and Atomicity.
Get the latest posts delivered to your inbox.