Hot Potato: the floor that won't budge (unless you're math)
TATER is a potato with a hidden floor that is proven never to drop and a reserve proven to cover every holder. Built on the shielded-note stack I've been assembling for Zera and Vanta. A plain-English tour of what a ZK proof actually buys you.
- FROM
- Dax the Dev <[email protected]>
- SOURCE
- https://blog.skill-issue.dev/blog/hot_potato_floor_vault/
- FILED
- 2026-10-25 18:00 UTC
- TIME
- 17 min read
- SERIES
- ZERA cryptography
- TAGS
Hot potato is a game where the whole point is to not be holding it when the music stops. Most tokens are that game with extra steps. The music is a group chat, the potato is whatever got listed this week, and the only question anyone is really asking is whether there’s a greater fool left.
I’ve spent the last two years building the opposite thing: shielded notes, the primitive under Zera, under Vanta, under ZeraSwap. A note is a commitment to an amount that only its owner can open, and a proof is how you spend it without showing anyone what’s inside. Once you have that primitive in your hands, a surprising number of “trust me” promises in crypto turn out to be things you can simply prove instead. This weekend I built one of them: a token with a floor. The floor’s value is hidden, but it is proven, every epoch, never to have gone down, and the reserve behind it is proven to cover every holder.
The token is called Hot Potato, ticker TATER. It’s the potato you never have
to drop, because the vault always takes it back. This post is a tour of how that
works, written for someone who has heard “ZK” a thousand times and still isn’t sure
what it buys you. Short version: the proof isn’t decoration here. It is the thing
that makes the promise real.
Hot potato, but you’re allowed to hold it
A floor-backed token is a simple promise with money behind it. A vault holds a reserve of some quote asset (USDC, say). It says: bring your tokens back and I’ll pay you F per token, and F will never go down. If you hold TATER and the market panics, you don’t need to find a buyer. You walk to the vault. That’s what “never have to drop it” means, and it’s the only sense in which a token can be honestly called undroppable.
Now the subtle part. If F is public, anyone can size a trade against it to the unit: sell exactly enough to drag the market to F, let the reserve absorb it, repeat. So F should be hidden. But a hidden floor, on its own, is a worthless promise. “There’s a number, trust me, it only goes up” is not something anyone should accept from anyone.
So the real requirement is: the value of F stays secret, and everyone can still verify that F never went down and that the reserve covers everyone at F. Prove a statement about a number without revealing the number.
That sentence is the definition of a zero-knowledge proof. The rest of this post is that sentence, built.
ZK proofs for people who’ve been nodding along for three years
A zero-knowledge proof lets me convince you that something is true without showing you the evidence. The toy version: “I know the password,” without saying the password. The grown-up version: “this computation, run on inputs you can’t see, produced this result you can see.” I hand you a small blob of bytes (128 of them here), you run a fixed check on it, and if the check passes, the statement is true. Not probably. True, unless someone has broken the elliptic-curve math the whole industry rests on.
Three properties make it work, and only one of them is the famous one:
- Completeness. If the statement is true, I can always produce a proof.
- Soundness. If it’s false, I can’t, no matter how clever I am.
- Zero-knowledge. The proof leaks nothing about my secret inputs.
Everyone fixates on the third. The useful one is the second. A ZK proof is a way to make a promise that physically cannot be broken, checked by a program that cannot be argued with. Privacy is what you get to keep while making it. That reframing took me embarrassingly long to arrive at, and once you have it, “where would a proof help” becomes an easy question: anywhere a promise currently relies on someone’s word.
“The floor went up and I can still pay everyone” was relying on someone’s word.
Why this sits on the Zera stack
Everything in Zera is built on the note: a commitment
Poseidon(amount, owner, salt) sitting in a Merkle tree. You spend it by proving
you know what’s inside, and the protocol records a nullifier so you can’t spend it
twice. That’s the shielded pool. The eleven-post
relayerless-privacy series is about how far that
primitive stretches; Vanta is the same primitive bolted into a Bitcoin fork, with
its own nullifier set; ZeraSwap was it as an AMM
over Light Protocol compressed tokens.
A floor-backed token is that primitive plus one extra statement. Your tokens are notes. Redeeming is spending a note. What comes out of a redemption is another note, holding your payout, hidden, so the floor can’t be read off the chain by dividing. The one new thing is a proof, each epoch, about the floor itself. I’ve written the ZK half of this three times now. This was the fourth, and the fastest, because the shape was already in my hands.
So floor_vault is:
- Notes as Light Protocol compressed accounts,
the same layer ZeraSwap sits on. Each note is a tiny account owned by the program
holding
\{ x, commit \}. Light nullifies it when it’s spent, with its own validity proof, so I didn’t write a nullifier set this time. I wrote enough of those for Vanta. - The reserve as an ordinary SPL token account with a public balance. On purpose. That decision turned out to be the most important one in the design, and it gets its own section.
- The floor as a single Poseidon commitment in the vault account:
Poseidon(F, blind). Only the operator knows what’s inside.
The four proofs that party on-chain
Each is a circom circuit, Groth16 over BN254, verified
on-chain through Solana’s alt_bn128 syscalls via groth16-solana. All four are
under two thousand constraints. That’s the thing nobody tells you about ZK in
production: once you’ve decided exactly what to prove, the circuits are small.
The deciding is the work.
Ratchet: “the floor went up, I won’t say by how much.” Public inputs: the old
floor commitment, the new one, the reserve balance R, and the outstanding supply
S. Private: the two openings. The circuit checks F_new ≥ F_old and
R · 2³² ≥ F_new · S. The floor is fixed-point with 32 fractional bits, so “1.5
quote per token” is the integer 1.5 · 2³², and a 64-bit reserve times 2³² stays
comfortably inside the field. The operator submits one of these every time the
floor moves or tokens get issued. If the vault can’t cover the new supply at the new
floor, there is no valid proof. I don’t mean it gets rejected on-chain; I mean the
prover refuses to produce one. The witness generator hits the solvency constraint
and throws before anything touches the network. I tested this by trying to
over-issue, and later by letting a randomised simulator try fifteen different
adversarial moves across fifty steps. Every one of them failed at the prover.
Redeem: “here’s your payout, inside an envelope.” Public: the floor commitment,
the note you’re spending, the note you’re creating, and x, how many tokens.
Private: the floor and its blinding, your old note’s secrets, your new note’s
secrets. The circuit checks that your note really holds x, that the commitment
really opens to F, and that your new note holds exactly ⌊x · F / 2³²⌋. The
division is done the usual way: the prover supplies the quotient and remainder as
private hints and the circuit checks x · F = payout · 2³² + rem with rem \< 2³².
You walk away with a note worth your payout. The chain saw x. It did not see F,
and it did not see the payout.
Claim: “open the envelope.” Later, you turn that payout note into actual money.
Public: the note, the amount, and your destination token account, split into two
128-bit field elements because a 32-byte pubkey doesn’t fit in one. Private: the
note’s secrets. The vault pays you and Light nullifies the note. Binding the
destination into the proof is what stops someone watching the mempool from copying
your proof and pointing it at their own account; my test does exactly that swap and
gets ProofInvalid back.
Spend: “this note is mine.” The fourth and smallest, for moving between notes and the SPL side. Same shape as claim, no payout.
An honest weakness, since I’d rather state it than have someone find it: if the
vault is quiet, a patient observer can link your claim to your redeem by timing and
amount, and back out F from the ratio. Zera’s pool is bigger and busier, and that
anonymity set is one of the reasons it exists. Here, the floor is hidden from the
lazy, and the bound F ≤ R·2³²/S is public to everyone.
Why the reserve is public
My first sketch hid the reserve R too, behind a commitment. It reads nicely: nobody knows how deep the backing is, so nobody can plan around it. Then I thought about what the solvency proof says under that design. “I know an R, an F, and an S such that R ≥ F · S.” The prover can pick any R they like. The proof is true about a number they invented. It is, precisely, a proof that they can multiply.
A ZK proof is only as strong as the link between its public inputs and the world. So the program reads the vault’s live token balance and the live token supply inside the instruction and hands those to the verifier as public inputs. A proof built against yesterday’s balance fails. The end-to-end test submits a ratchet with a stale R and watches it bounce. That one change is the whole difference between “proof of reserves” and a PDF.
The cost is that observers learn an upper bound on the floor. I’ll take it. The guarantee is public; the number isn’t.
The SPL side: TATER
A shielded note can’t be listed on a DEX, so the vault also owns a plain SPL mint,
TATER, one-to-one with notes. shield burns TATER and creates a note; no proof
needed, because the burn is the payment and the note’s amount is public data.
unshield proves you own a note (the spend circuit) and mints TATER back. TATER
supply is read live from the mint and counted in S for every ratchet proof, so
TATER holders sit under the same floor as note holders. The mint has no freeze
authority.
The market for it is an ordinary two-way Meteora DAMM v2 pool: flat 30 bps fee, full range, no alpha vault, liquidity unlocked. My verification script sells into it and buys from it and reads the fee back off the chain. The sell moved the price from 1.50 to 1.36; the buy brought it back to 1.51. A sell moves the price down. That’s a market. The floor lives underneath it, in the vault, and it doesn’t care what the pool is doing.
What the tests actually exercise
I don’t trust a ZK system until the negative cases are tested, because the whole value is in what can’t happen. The suite, against a local Light stack:
- init, deposit, issue (with ratchet proof), redeem, claim: the happy path, compute units measured per instruction.
- a second issue that is insolvent: unprovable, the prover throws.
- a claim replayed after it succeeded: rejected by Light’s nullifier check, not by
my own bookkeeping (the test issues two notes so my
pendingcounter can’t be the thing that saves it). - a valid claim proof pointed at a different recipient:
ProofInvalid. - a ratchet signed by the wrong key: Anchor constraint failure.
- a ratchet proof built against a stale reserve:
ProofInvalid. - shield → unshield round trip: supply conserved, note nullified.
Plus a fifty-step randomised simulation that plays the whole state machine with
real proofs and checks the invariant R · 2³² ≥ F · (S + pending) after every
step, and five native Rust tests that push snarkjs proofs through the exact verify
path the program uses on-chain.
Numbers, for the people who scrolled straight here
| heaviest instruction (claim: Groth16 + Light CPI + SPL transfer) | 165k CU |
| ratchet alone | 101k CU |
| proof on the wire | 256 bytes (128 compressed) |
| circuits | 4, all < 2k constraints |
| metadata | hot-potato.json |
Mainnet (small first run, SOL as the quote asset; same program id on both clusters):
| program | 4d3CvyKeyH42e1HECTpLeKYyRUP376zYuZ1QBUnTPZnh |
| TATER mint | 4Br4NDUmkgQHuYhX9KmLUg47gGGw6MRCpMcebg8yQ2wZ |
| vault | G81nMdxMiiDpKGkn2CqSXfnXHGoBErCy6cBZinBS6ZTK |
| reserve / issued | 0.5 SOL backing 300 TATER, floor 0.0015 SOL per TATER |
| Meteora pool | CPycDZCCtHnBKtkuEx4naBVKfGxT37BFYpJQac74KxgU TATER/SOL, 30 bps |
The chart
Live TATER/SOL from the pool, via GeckoTerminal. This is the market price. It goes both ways; that’s the point of a market. The floor underneath it only ratchets up, and you can watch the epoch counter in the widget below tick each time it does.
Other views: DexScreener,
Meteora,
and Jupiter routes it (TATER by mint address until their token list catches up).
Buy a potato
This trades against the mainnet pool from your own wallet (Phantom or Solflare). It’s the market side; the floor is the separate exit at the vault.
- pool price
- …
- vault reserve
- …
- TATER issued
- …
- floor epoch
- …
Hand TATER back to the vault at the floor, in three signed steps. Secrets stay in this browser (export). Floor ceiling: … SOL/TATER; the exact floor is revealed to you at step 2.
Mint new TATER straight from the vault. You pay the higher of 2× backing and 90% of the pool price, and every lamport lands in the reserve, so each mint raises backing per token. Rate-limited on-chain: … TATER per hour, hard cap, unused allowance does not bank. Mintable right now: … TATER.
- mint price
- …
- pool price
- …
- backing R/S
- …
Trade: hits the pool directly; quotes move fast on a small pool, raise slippage if rejected. Redeem: the vault pays the floor regardless of the market. Mint: the vault sells new TATER above backing, proceeds to the reserve, throttled on-chain. The floor is a separate exit at the vault; see the post. Meteora · Solscan · pool
Issuance policy, and a mistake
The vault can mint TATER, but only what the reserve already backs at the floor; the ratchet proof enforces it. There’s a script that uses that: when the market trades far above backing, it mints a small amount, sells it into the pool, and deposits every lamport of SOL it receives into the reserve. Each run raises backing per token and lets the next ratchet lift the floor. It is sell pressure by design, and the rule is public: it acts only above 8× backing, at most 3% of the pool’s TATER per run, never automatically. The next section replaces it with something that does not need me at all.
The first run, on launch day, used 20% per cycle on a 55-TATER pool and cut the
price in half in two transactions (20:37 and 20:38 UTC; the sells from the vault
authority wallet, 6SnLV…Ljkz). That was my sizing error, not the market. I bought
0.3 SOL back from my own wallet, added the same again as liquidity, and shrank the
policy to the numbers above. The reserve kept the 0.32 SOL the sells raised; the
floor ratcheted from 0.0016 to 0.0023 SOL on it. If you bought in that window and
want the full list of what the authority wallet has done, every transaction is on
the pool’s history and in the repo’s deploy log.
Day two: the sniper dump
Then the market did the thing. Overnight, six outside wallets bought about 2 SOL of
TATER into a pool holding 1.3 SOL, and spot went from 0.018 to 0.083 SOL, 33× backing.
At 03:31 UTC on the 26th, wallet FBfChx… sold 23.17 TATER into a pool that held
33 TATER and took 1.44 SOL out in one transaction. Price went 0.083 → 0.035, −58% on
the hour. The TATER came from the first ten minutes after launch: five fresh wallets
bought 6–11 TATER each for 0.025–0.1 SOL (about 45 TATER for 0.24 SOL, total), then
consolidated and sold at the top. 0.03 SOL in, 1.44 SOL out. The vault authority
wallet did nothing in that window; every transaction is on the pool’s history.
Nothing in the design prevents a holder from selling, and nothing should. What the episode shows is the gap: at 33× backing, the reserve captured none of the premium. That 1.44 SOL left the system. The mechanism below exists so that the next time the market runs ahead of backing, the vault is the one quietly selling into it, 0.25 TATER an hour, and the SOL lands under the floor instead of in a sniper’s wallet. The backing ceiling is unchanged by the dump: 0.822 SOL over ~325 TATER, 0.00253 SOL/TATER, and spot is now ~17× that instead of 33×.
Mint at a premium: issuance without an operator
The issuance script above has an obvious problem: I run it, I pick the size, and I
got it wrong once already. So the program now has a permissionless version with the
sizing taken out of my hands. Anyone can mint TATER straight from the vault with
mint_at_premium. The price per token is the higher of two numbers, both public:
- 2× backing, where backing is reserve over supply (R/S). Never lower, so every mint raises R/S and the floor’s headroom only grows.
- 90% of the pool price, read from the Meteora pool account in the same transaction. When the market trades far above backing, this is the one that binds.
Every lamport paid goes into the reserve. No proof is needed for this one: R and S are public, the floor F is at most R/S, and (R + cost)/(S + x) is at least R/S by construction, so the solvency invariant holds without opening the commitment.
Here is what it does and does not do, in plain terms. When spot sits at 30× backing, minting at 90% of spot and selling into the pool is free money, so bots will do it until spot comes down near the mint price. That moves SOL out of the pool and into the vault: the reserve grows, backing per token grows, the next ratchet lifts the floor. It does not add liquidity to the pool. The pool ends up TATER-heavier and shallower on the SOL side; what gets thicker is the thing under the floor, not the thing under the chart. The chart goes down while it happens. That is the honest shape of it: a premium that large is a gap between what people paid and what the vault holds, and the only ways to close it are more SOL in the vault or a lower price. This mechanism does both, slowly.
Slowly is enforced on-chain. The config holds a token bucket: minting refills at a fixed rate (mainnet: 0.25 TATER per hour, hard cap) up to a bucket of the same size, so unused allowance never banks and nobody can take a day’s worth in one block. At that rate, closing today’s gap takes on the order of a week or two, which is the point: pool buyers get days to decide, not seconds, and the sell pressure is small enough that ordinary buying can outrun it. The authority (me) can retune the premium (never below 1× backing, the program refuses), the spot fraction, the rate, or pause it. The one thing no setting allows is minting below backing.
The widget above has a Mint tab that shows the live price, the bucket, and does the whole thing in one signed transaction. Expect it to say “0.0000 mintable” most of the time; a bot takes each refill within seconds, which is fine for the vault, it still gets ~90% of spot per token.
Mainnet status: program upgraded and config live when the stats widget shows a mint price instead of “not enabled”. The first mint config on mainnet is logged in the repo’s deploy log with its signature.
Pool and vault, live
Everything below is read from chain every thirty seconds. The two columns are the two prices: what the market says, and what the vault guarantees. The floor itself stays hidden; what you get is its public ceiling, reserve over supply.
- spot
- …
- pool holds
- …
- TVL
- …
- 24h volume
- …
- 24h txns
- …
- market cap
- …
last trades
| when (UTC) | side | TATER | SOL | price | tx |
|---|
- reserve R
- …
- supply S
- …
- backing R/S
- …
- floor
- …
- epoch
- …
- mint price
- …
- mintable now
- …
- last ratchet
- …
- commitment
- …
R/S is a hard ceiling on it:
a redemption can never pay more than that per TATER, and the floor is proven never to fall.
epoch log
| epoch | when (UTC) | reserve | supply | ceiling | tx |
|---|
Devnet playground. The whole system also runs on devnet with faucet SOL as the quote, same program and same widgets, looser mint policy so the mechanics show up in minutes instead of days: blog.skill-issue.dev/tater/devnet. Switch your wallet to devnet and break it.
Two gotchas for anyone building on Light’s v2 CPI, each of which cost me an hour:
the indexers decode invoke_cpi_with_read_only but not light-sdk’s default
with_account_info shape, and the event parser assumes insert_into_queues is the
last inner instruction in the transaction. Both are one-line fixes once you know.
What’s still missing
The mainnet run above is deliberately tiny, and it’s running on a single-party trusted setup (the phase-1 ptau came from my laptop). Before it holds anything that matters: a real multi-party ceremony, an audit, and a note-transfer circuit so value can move between people without going through TATER. Done since launch day: owner-bound notes (a redeem needs the holder’s signature, not just the secrets), the permissionless mint with its on-chain rate limit, the devnet playground, and metadata with a dancing potato on it.
Buy, sell, mint, redeem, repeat. The vault’s open either way.