Atomic Settlement in Redis: Charging Before Crediting
A marketplace that credits a seller before charging the buyer will eventually pay out money it never collected. We found 27 routes doing exactly that. This is the fix, and the race condition still open in the same code.
The defect class
Settlement has two halves: take money from the buyer, give a share to the seller. Written casually, they end up as two independent operations, and any failure between them leaves the ledger inconsistent in one of two directions.
Credit-then-charge is the dangerous order. If the charge fails, the seller has earnings with no corresponding revenue. Nothing errors, nothing alerts, and the discrepancy is invisible until someone reconciles — which for most early-stage marketplaces is never.
We audited our own routes and found 27 displaying real prices while performing no charge at all. Earnings accrued; revenue did not. The numbers were small because volume was small, and that was luck rather than design.
Making the ordering structural
The fix is not to remember the order. It is to make the wrong order impossible to express, by routing every paid operation through a single helper that charges first and only credits on success.
const charge = await liveCharge(keyHash, pence, invokeId)
if (!charge.ok) return { error: charge.reason }
// credit only reachable past a successful charge
await creditSeller(...)A convention that lives in review comments decays. A chokepoint that makes the invariant unrepresentable does not.
Why the charge is Lua
Check-then-set over the network is not atomic. Read a balance, decide it is sufficient, write the decrement — and two concurrent requests both read the same balance and both proceed. At sub-penny amounts and machine concurrency, that window is hit far more often than intuition suggests.
Redis executes Lua atomically, so the check and the write happen without interleaving:
-- balance check, decrement, and idempotency guard
-- all inside one atomic execution
if redis.call("SISMEMBER", KEYS[3], ARGV[2]) == 1 then
return {0, "duplicate", redis.call("HGET", KEYS[1], "balance_pence")}
end
-- ... check sufficiency, decrement, record invokeIdThe third key is idempotency. Agents retry — on timeout, on network error, on their own orchestration logic — and a retried invocation must not charge twice. Recording the invocation ID inside the same atomic block means the duplicate check cannot be raced either.
The split, and a comment that read wrong
Our developer share is 78% of gross. Two code paths compute it and both are correct, but they arrive differently: one multiplies gross by 0.78 directly; the other takes 2.5% first, then 80% of what remains. Since 0.975 × 0.80 = 0.78 exactly, they agree — at 100p both pay 78, at 500p both pay 390.
The comment above the second path said "2.5% dreamTax then 80/20 split", which reads as contradicting the published 78%. It did not, but a comment that appears to contradict your public figure is a real problem in a codebase people are invited to inspect.
Worth noting the paths diverge at very small amounts, where flooring the 2.5% tax to zero changes the result: at 10p one pays 7p and the other 8p. Two paths that should be equivalent are not, and that is logged rather than quietly tolerated.
The race we have not closed
The atomic charge is Lua-gated. The settlement pipeline around it is not fully transactional, and a check-then-set window remains. It was accepted for v1 on the basis that transaction IDs are unique and the window is narrow. A SETNX-gated settle is the planned hardening and it is not done.
We are stating that publicly because a marketplace that only discusses its solved problems is telling you less than it appears to. If you are building settlement, the ordering fix above is worth copying immediately. The race is worth designing out from the start rather than accepting and deferring, as we did.
Deploy on ForceDream today
Free account. 78% developer earnings enforced at L828. WORM-sealed from call one. 200 markets.