← writing

Verifiable isn't trustless: a coin flip on Sui

A blockchain is a machine built to never surprise itself: every node has to compute the same result from the same inputs. So where do you get a random number to settle a coin flip? Anything already on-chain — a block hash, a timestamp — is visible to whoever produces the block, and they’ll re-roll it until they like the outcome. You have to bring the randomness in from somewhere, and how you bring it in is the whole security story.

I picked up an old Sui Move game of mine, satoshi_flip, to look at exactly this. It settles a bet with house-signed randomness: the player makes a guess, the house signs the game’s id with a BLS12-381 key, and the hash of that signature is the coin. The appeal is that it’s verifiable — the signature is checked on-chain against the house’s public key, so anyone can confirm the house didn’t just type in “you lose.”

Verifiable, and still rigged

Here’s the part that’s easy to wave past. A BLS signature is deterministic: for a given key and message there is exactly one valid signature. People reach for that as a safety property — “the house can’t re-sign until it gets a result it likes.” True. But run the determinism the other direction: the house can compute the one signature, and therefore the outcome, off-chain, before it does anything on-chain. It can’t change a game’s result — but it never has to settle a game it’s going to lose.

Where the trust hides: house-signed BLS vs native randomness Where the trust hides BLS — verifiable, but house-trusted player bets guess + stake house signs the game id deterministic → outcome known off-chain settles a loss? only if the house wins The signature is honest. Choosing which games to settle is the rig. Native — trustless validator DKG beacon 0x8 · no one knows it early entry fun settle(&Random) must be entry, not public anyone settles outcome is fair Trustless — but make it public and a caller previews the result and aborts on a loss.
BLS is verifiable but house-trusted: the house knows the outcome before it settles. Native randomness removes that power — at the cost of one sharp edge (it must be an entry function).

That’s the difference between verifiable and trustless, and the two get conflated constantly. Verifiable means you can check that the rule was followed. Trustless means no party had a lever to pull. House-signed BLS is the first without the second: every settled game is provably fair, and the set of games that got settled is quietly hand-picked. For a low-stakes, accountable house it’s a reasonable trade. As “provably fair gaming,” it’s a half-truth.

The trustless version — and its sharp edge

Sui ships the honest fix: a native Random object (0x8) backed by the validators’ distributed key generation. No single validator knows the seed; you’d need a third of them colluding. You seed a local generator from that beacon and the transaction, and nobody — house included — knows the result until the transaction has already committed. I implemented it alongside the BLS path as finish_game_native, and it’s strictly better here: it’s unbiased (the old byte % sides is slightly skewed when the die’s faces don’t divide 256), and anyone can settle, which deletes the selective-participation lever entirely.

But native randomness has a footgun that’s worth the whole post on its own:

The function that consumes &Random must be a private entry function — not public.

Make it public and you’ve reopened the bias from a new direction. A public function can be called by another Move function, so a caller can wrap your settle call, read the result, and abort the whole transaction if it’s a loss — paying only gas and retrying until it wins. “Preview-and-abort.” An entry function can’t be called from other Move code — only as a top-level transaction command — so its effects can’t be inspected-and-reverted by a composing caller. The fix for one bias attack is exactly the surface for another, one keyword away.

A footnote that became the first thing I fixed

I should admit how this started. Before any of the randomness analysis, I ran sui move build. It failed. The dice module had been committed in a state that never compiled — it called functions that didn’t exist on the house object and borrowed a value it had moved. And because the package didn’t build, not one of its tests had ever run — including the fact that the dice game had no tests at all, and the coin flip’s tests quietly bypassed the BLS verification they were supposedly covering.

This is the same lesson the negation work and the bridge keep handing me from different directions: code you haven’t run is not code that works, it’s a draft you’re hoping about. A test that can’t fail — because the build is red, or because it routes around the thing it claims to test — proves nothing. The repo now builds, both settlement paths are real, the fee is charged once instead of twice (it was), and there are nineteen tests that actually exercise the BLS rejection, the native path, and the dice game.

The runnable version — both paths, the threat model, and the entry-vs-public footgun written down where the next person will see it — is public: satoshi_flip. The one-line takeaway I keep: verifiable tells you the result was computed honestly. It does not tell you the game was fair. For that, check who could have walked away.