The most important thing a wagering app can do is move the right amount of money to the right person. Everything else — the leaderboards, the AI, the hole-by-hole drama — is set dressing. If the number in your wallet is wrong, none of the rest of it matters.
So last week we went through ours with a fine-tooth comb and found out how wrong things actually were.
This is a post about what we broke, why we broke it, and how we fixed it. We're writing it because we think transparency on money stuff is not optional. If something touches your wager, you deserve to know about it.
The Most Ironic Bug in a Wagering App
The one that landed first should have been caught in week one.
The "you owe / are owed" feed — the surface that tells you what you need to pay after a round — was dividing every settlement amount by one million before displaying it. If your group played a round and you owed $5.00 in cfUSD, the feed showed $0.00. Everyone owed zero. Everyone was owed zero. A three-hour money game that settled into a screen full of zeroes.
This happened because an early assumption baked into that endpoint was that amounts were stored in micro-units (the way on-chain contracts often work, where $1.00 becomes 1,000,000). The rest of the system had moved on from that convention, but this one endpoint never caught up. It kept dividing by a million that wasn't there.
The data was fine. The actual settlement records held the right numbers. Only the display was broken. But in a wagering context, "only the display" is not a small thing — the number people see is the number they trust.
We removed the divide. The fix was one line. The damage it could do to trust in the product was considerably more than one line.
Three Writers, One Column
The deeper problem was what we found when we traced where settlement amounts come from.
The WalletTransaction.amount column — the ledger that tracks every cfUSD movement — had three different conventions for what a number meant, depending on which part of the code wrote it. A minority of writers stored amounts in wei (the base unit on Ethereum, where $1.00 is a very large integer). Most stored decimal cfUSD, where $5.00 is just 5.00. Some stored something in between.
When you sum a column where some rows are in wei and others are in decimal, you get answers that are orders of magnitude off. Balance totals, lifetime earnings, wallet history — all of them adding together numbers that weren't the same unit.
We picked one convention (human-readable decimal cfUSD, because anyone should be able to read the database and understand what they're looking at), wrote a migration to backfill every existing row to that standard, and audited every code path that writes to the ledger to make sure they all speak the same language now. It was the kind of thing that should have been enforced from the beginning with a clear internal convention. It wasn't, and so two years of organic code growth created three different authors with three different assumptions, all writing to the same table.
The ledger is clean now. Every number means the same thing.
The Scratch Player Who Always Settled Wrong
The third bug was quieter, and in some ways more maddening.
caddie.fun supports net scoring — where strokes are adjusted for handicap so players of different skill levels can compete fairly. Better-than-scratch players (players with a plus handicap, meaning they're actually better than par) need special handling: their strokes go the other way. A +2 player doesn't get strokes added; they give strokes to the field.
Plus-handicap players were being silently dropped at round start. Not errored out, not flagged — just quietly excluded from the net handicap calculation. The round would proceed and they'd be settled on gross scores, as if net scoring wasn't in play at all. An above-scratch player in a net game was being evaluated as though handicap didn't exist.
This is a narrow slice of the user base. But it's also exactly the kind of player who cares most about getting it right. A plus handicap knows exactly what they should be giving up and they'd notice immediately if the math didn't reflect it.
We fixed the round initialization logic to handle plus values correctly and added tests specifically for the edge cases around scratch and above-scratch. The fix was not complicated. The fact that it got through is a lesson in how easy it is to forget the tails of a distribution when you're building for the middle.
What the Audit Actually Looked Like
We didn't find these bugs through user reports. We found them by deciding we were going to trace every dollar through the system, table by table, and verify that the data at each step was what it was supposed to be.
The process was straightforward and slow: pick a table, read every column definition, trace every writer and every reader, follow a real record through its lifecycle and check the actual values against what the code claimed they'd be. Thirty-five tables took about a week. You find things that seem fine until you look at live data. You find things that seem broken until you realize they're handled correctly three layers up. You find things that are just quietly wrong and have been for a while.
Alongside the data integrity work, we also fixed the MatchVault's totalDeposits field — it was permanently zero, so any admin view of funded rounds showed $0 in the vault no matter what was actually deposited. That one was an artifact of a contract-read that had been stubbed out and never wired to the real on-chain value. Also: Venmo deeplinks were freezing on stale settlement amounts instead of updating when the amount changed; disputed settlement reasons were being discarded instead of persisted. All caught in the sweep.
None of these were dramatic failures. cfUSD is still testnet-only. No real money was ever misrouted. But the pattern they represent — quiet accumulation of assumptions that are never checked against live data — is exactly how a real-money product fails when you try to cross that line. We'd rather learn that lesson now, in a controlled environment, than find it by surprise.
The Actual Takeaway
There is a version of this post where we say "we found a display bug and fixed it" and call it done. That would be technically accurate and completely dishonest. The display bug was easy. The thing underneath it — three different unit conventions in the same column, settlement endpoints that never got checked against real ledger data, plus-handicap edge cases that fell through silently — is the actual story.
Building something that people will trust with their money means accepting that you will find things like this and that finding them is the job, not a failure. We found them. We fixed them. We wrote the invariant checks so the same class of thing trips a CI gate instead of a user report.
The ledger is clean. The math is right. Go play.