Inspiration
In a real card room, when you lose, you muck. You slide your cards face down and nobody ever learns what you were holding. That isn't politeness, it's strategy — every hand you show is a permanent read on how you play, and good players pay for that information.
On-chain poker throws it away. Showdown means publishing your hole cards to a public ledger where they are indexed, free, and permanent. Your opponents don't need tracking software. The chain IS the tracking software.
What it does
Nightfold is heads-up Texas Hold'em where the losing hand is never published, and where the money can come from — and leave on — different chains.
The showdown is a disclosure ladder, not a reveal
Midnight holds the cards as witnesses; they never leave your machine. The ledger sees commitments and, at most, a single number. At showdown you choose how much that number says:
| Circuit | What you say | What reaches the ledger |
|---|---|---|
revealHand() |
"My hand ranks 2,169,397." | The category and every tiebreaker — the whole composition. |
proveAtLeast() |
"My hand is at least two pair." | That one bound. Not the rank, not by how much. |
beatOpponent() |
"My hand beats the one you showed." | That the comparison held. Nothing about your own rank. |
muckHand() |
"I concede." | Nothing. No cards, no rank, no proof of holdings. |
Most on-chain poker has the top rung and nothing else. Two of these leave the ledger with nothing readable about what you held.
Chips are the unit of account, and every chain agrees on the price
A chip costs $0.20 whichever chain you bring. Rates are derived from one USD table, never chosen per chain — because rates that disagree are free money for whoever spots them.
Six chains, three integration modes:
- Native — the cage contract runs on that chain and holds the deposit itself. Base, Ethereum, every EVM chain, from one Solidity file.
- Watched — no cage runs there, but a real watcher reads the chain over RPC and reports deposits it has actually seen. Solana.
- Attested — the cage verifies a signed claim about that chain. Bitcoin, Cardano, NEAR.
You can leave on a chain you never arrived on
The chips burn on the source cage before anything is paid anywhere — that ordering is what stops one stack being spent on two chains.
How we built it
| Layer | What it does |
|---|---|
| Midnight / Compact | 9 circuits. Hole cards, salts and stacks are witnesses. The ledger stores commitments, at most one rank, and a payout attestation. |
| Solidity | NightfoldCage (custody + cross-chain chip ledger), NightfoldTable (on-chain betting), NightfoldEscrow. |
| Solana | A dependency-free devnet watcher that reads real deposits, plus a relayer that pays real SOL out. |
| React / Vite | The table, the cage, and a "public chain view" panel that renders exactly what the chains can see — and nothing more. |
A relayer carries settlements between chains but cannot author them: payouts need signatures from a watcher quorum the relayer is not part of, bounded by a reserve cap and stoppable by a bonded challenge.
Challenges we ran into
Every circuit call was rejected by the proof server for two days. bad input, HTTP 400, in about 3 milliseconds — a refusal before any work happened. Deploys proved fine. A bare-counter contract with no witnesses at all failed identically, which ruled out our contract. We systematically eliminated compiler/runtime/ledger version skew, duplicate WASM runtimes, missing proving keys, asset paths, and payload size. None of it was the problem.
The cause was one missing argument:
httpClientProofProvider(env.proofServer) // before
httpClientProofProvider(env.proofServer, zkConfigProvider) // after
It takes three arguments. With the second one absent, the SDK does this:
const getKeyMaterial = async (zkConfigProvider, keyLocation) => {
try { return zkConfigToProvingKeyMaterial(await zkConfigProvider.get(keyLocation)); }
catch { return undefined; } // swallows "undefined.get is not a function"
};
A TypeError became a silent undefined, undefined became None, and None became bad input from a server three layers away. We found it by logging the request tag through a proxy: the tag the SDK sent matched what the server wanted exactly, and the whole body was 143 bytes ending in 00 — option(wrapped-ir) = None. The request carried no circuit IR at all.
Getting the security right took four passes. We audited the contracts, re-audited them, had the remediations independently verified, then swept them again before deploying. Every exploit any of those passes could execute is now a regression test that re-runs the attack and asserts it fails — because a feature test fails loudly when someone breaks it, but a missing exploit test fails silently forever.
Accomplishments that we're proud of
Real ZK proofs on a local Midnight devnet. A whole hand, proved and landed:
deploy 19.4s
openHand 30.6s
revealHand seat 1 29.3s
muckHand seat 0 24.0s
settle 24.0s
------------------------------------
hand total 107.8s (4 transactions)
ledger: 1 hand, 1 rank, 1 muck, 1 settled
One rank public. One hand hidden, forever.
A real cross-chain loop, end to end, with a wallet. Deposit ETH into the cage on one chain, play a hand, then burn and receive real SOL on Solana devnet — 0.947775629 SOL, which is exactly 490 chips at $0.20, correct to the lamport, with a signature anyone can open in an explorer.
348 assertions, zero failures — covering every circuit, both cages, the betting contract, the Solana watcher and the full cross-chain loop, including a 20,000-hand fuzz against an independent reference evaluator.
What we learned
A bare catch that discards its error turned a one-line mistake into a two-day hunt. That is now a rule in this repo, not a preference.
Exploit tests matter more than feature tests. A feature test fails loudly when someone breaks it. A missing exploit test fails silently forever.
Publishing a packed rank publishes the hand. 2,169,397 decodes to "two pair, aces and kings, nine kicker" — the category and every tiebreaker. Discovering that mid-build is exactly why the muck and the partial proofs exist, and why "we publish the rank, not the cards" is not privacy.
What's next for Nightfold
Private stacks are the piece we're most interested in finishing. The circuits already work — proveCanCover proves your stack covers a bet without revealing it, and spendFromStack proves the new commitment holds exactly the remainder under a fresh salt, so two commitments by the same player can't be linked. But NightfoldTable still holds chips in a public mapping. Closing that needs the cage to attest an opening balance and the table to take a proof instead of reading a number.
Hiding the cards while publishing the stack only moves the leak: a visible balance says who is short and who is pot-committed, and a public ledger says it forever.
After that: multi-seat tables, and a real Solana cage program so Solana is native rather than watched.
Built With
- anvil
- base
- compact
- ethereum
- foundry
- metamask
- midnight
- node.js
- react
- solana
- solidity
- three.js
- typescript
- viem
- vite
- zero-knowledge
Log in or sign up for Devpost to join the conversation.