One NFT, on a fifteen-minute clock
There is exactly one Hot Potato in existence. You get into the draw two ways: by trading $POTATO and by holding it. Every trade pays a 2% tax that pools into the round's pot. At the close of each fifteen-minute round the machine pays the wallet that was holding the potato half the pot, uses a quarter to buy back and burn $POTATO, rolls the last quarter into the next pot, then draws a new holder at random — weighted by entries — and force-transfers the potato to them.
The interesting part is the word force. A holder can never transfer the potato, sell it, or burn it. Only the game can move it, and the game always can. That asymmetry is the whole design, and it is built entirely from standard Solana Token-2022 features — no custom on-chain program.
Architecture at a glance
The system is three moving parts plus the chain. Nothing here is a smart contract we wrote — the on-chain logic is entirely SPL Token-2022 and Meteora's audited pool programs. Our code is an off-chain worker that reads chain state and signs transactions, and a read-only website.
The website never touches a private key and never talks to the worker directly — it only reads a JSON snapshot. Everything the worker does is a normal transaction signed by one wallet, which means every action is independently verifiable on a block explorer.
The Hot Potato NFT
The potato is a Token-2022 mint with a supply of one and zero decimals — a classic 1-of-1 — but initialized with three extensions baked into the mint at creation. Extensions are the Token-2022 mechanism for attaching extra behavior to a mint or token account without a custom program.
All three are installed in a single creation transaction, the one token is minted to the house wallet, and then the mint authority is revoked so the supply can never exceed one. The order matters: extensions must be initialized before InitializeMint.
createInitializeMetadataPointerInstruction(mint, auth, mint, TOKEN_2022),
createInitializePermanentDelegateInstruction(mint, auth, TOKEN_2022),
createInitializeDefaultAccountStateInstruction(mint, AccountState.Frozen, TOKEN_2022),
createInitializeMintInstruction(mint, 0, auth, auth, TOKEN_2022), // 0 decimals
createInitializeMetadataInstruction({ mint, name, symbol, uri, ... }),
// mint the single token to our ATA, then lock supply forever:
createMintToInstruction(mint, ata, auth, 1n, [], TOKEN_2022),
createSetAuthorityInstruction(mint, auth, AuthorityType.MintTokens, null, [], TOKEN_2022),The auth in every line above is the house wallet. It ends up holding the permanent-delegate authority, the freeze authority, and the metadata update authority — the three powers the game relies on.
Permanent delegate authority
In ordinary SPL tokens, a "delegate" is an address the account owner approves to move some amount on their behalf — and the owner can revoke it at any time. Token-2022's PermanentDelegate extension is different in two decisive ways:
- It is set on the mint, not granted per account — so it applies to every token account of this mint, including ones that don't exist yet.
- It cannot be revoked by holders. No matter who receives the potato, the permanent delegate retains unconditional authority to transfer (or burn) their token.
That is precisely what a hot-potato game needs: when a round ends, the game must be able to take the potato out of the winner's wallet and place it in the next winner's wallet without their signature. The permanent delegate makes the game a co-signer on the token forever.
Frozen by default
The permanent delegate lets the game move the potato. To stop the holder from moving it, we use the DefaultAccountState extension set to Frozen. With it, every token account ever opened for this mint is born frozen. A frozen account cannot send, receive into, or burn its balance — any such instruction fails immediately.
Because the house wallet is the mint's freeze authority, only the game can thaw an account, and it only ever does so for the few microseconds of a transfer before freezing it again. So at rest, the potato is always frozen in someone's wallet: visible, ownable, but inert.
Passing the potato
Handing the potato to a new winner is one atomic transaction signed only by the house wallet. It wears two hats at once — freeze authority to thaw and re-freeze, permanent delegate to do the transfer the holder never authorized:
thawAccount(fromAta, mint, freezeAuthority) // unlock sender
// create + thaw the winner's account if it's new (born frozen)
transferChecked(fromAta → toAta, 1, permanentDelegate) // <- no holder signature
freezeAccount(toAta, mint, freezeAuthority) // lock winner
freezeAccount(fromAta, mint, freezeAuthority) // lock old accountThe transfer authority on that middle instruction is the permanent delegate, not the account owner — which is why the previous holder never has to (and never gets to) approve it. The thaw/freeze bracket means the potato is exposed for exactly one instruction and is frozen again before the transaction ends.
Proven on mainnet
These properties aren't theoretical. We minted the NFT, handed it to a throwaway wallet, and from that wallet attempted to transfer it and to burn it — then reclaimed it with the permanent delegate. The results, on mainnet:
| Action, attempted by the holder | Result |
|---|---|
| Transfer the potato to another wallet | Rejected — Error: Account is frozen |
| Burn the potato | Rejected — Error: Account is frozen |
| Reclaim to the house via permanent delegate | Succeeded |
Holders cannot escape the game, and the game can always retrieve the potato. Both halves of the custody model hold.
The token & the tax
The prize is funded by a trading tax on $POTATO, collected by Meteora. The token launches on a Dynamic Bonding Curve (DBC) and, once it bonds, migrates to a DAMM v2 pool. We configure both so that:
- Every trade pays a 2% fee (200 bps), charged in SOL only (quote-only collection), so the prize accrues as clean SOL rather than a mix of tokens.
- 100% of trading fees route to the creator — the house wallet — which is the revenue the game pays out.
- On migration, 100% of the liquidity is permanently locked to the creator. Liquidity can never be pulled, but the fees that locked position earns remain claimable forever.
fee: {
baseFeeParams: { /* 200 bps, fixed */ },
collectFeeMode: CollectFeeMode.QuoteToken, // fees in SOL
creatorTradingFeePercentage: 100, // all to the house
},
migration: {
migrationOption: MigrationOption.MET_DAMM_V2,
migratedPoolFee: { poolFeeBps: 200, collectFeeMode: QuoteToken },
},
liquidityDistribution: {
creatorPermanentLockedLiquidityPercentage: 100, // LP locked forever
},The fee engine
So the displayed pot stays accurate to the second, the worker claims fees every 30 seconds. It doesn't track a single pool — it enumerates every DAMM v2 position the house wallet holds and claims any with a pending balance, then unwraps the resulting wSOL into native SOL.
const positions = await cpAmm.getPositionsByUser(wallet.publicKey);
for (const pos of positions) {
const pending = getUnClaimLpFee(poolState, pos.positionState);
if (pending.feeTokenA.isZero() && pending.feeTokenB.isZero()) continue;
await cpAmm.claimPositionFee({ owner: wallet.publicKey, position: pos.position, ... });
}
// turn claimed wSOL back into native SOL so the payout sees plain balance
await unwrapWsol(connection, wallet);Claiming fees from a permanently-locked liquidity position is allowed — only the liquidity itself is immovable. This is what turns "locked LP" into a perpetual revenue stream for the prize.
The raffle
Entries come from two independent sources, added together per wallet: recent trading volume (entries that decay as they age out of the hour) and $POTATO holdings (permanent entries that stand every round). Both are read straight from the chain at draw time.
Holding entries
Every 25,000 $POTATO a wallet holds is worth one permanent entry, recomputed each round from live balances — so simply holding keeps you in the draw even when you're not trading. Pool vaults and other program-derived holders are excluded (see below), so locked liquidity never wins.
Volume entries
For each transaction that touched the pool, the worker measures the absolute change in the pool's SOL vault — that's the trade size — and credits it to the transaction's fee payer.
Only real wallets can win. Any program-derived address (off the ed25519 curve) — pool vaults, LP and AMM accounts, escrows — is disqualified, so liquidity infrastructure can never be drawn as a holder. Fee payers must be signers, so this also guarantees a winner that can actually receive the potato.
Recency-weighted entries
Fresh volume is worth more. Volume is bucketed by age and converted to whole entries at a tiered dollar rate:
| Trade age | Cost per entry | Relative weight |
|---|---|---|
| 0–15 minutes | $1 of volume = 1 entry | 4× |
| 15–30 minutes | $2 of volume = 1 entry | 2× |
| 30–60 minutes | $4 of volume = 1 entry | 1× |
// trader = transaction fee payer; size = |Δ SOL in the pool's quote vault|
if (!PublicKey.isOnCurve(signer.toBytes())) continue; // drop PDAs (vaults/LPs)
const usd = (deltaLamports / 1e9) * solPriceUsd;
entries += Math.floor(usd / tier.usdPerEntry); // floored per age bucketThe draw
Every entry is one ticket. The winner is drawn with a cryptographically-secure RNG (node:crypto), uniform over all tickets — so your odds are exactly your share of total entries.
let ticket = randomInt(totalEntries); // CSPRNG, 0 .. total-1
for (const t of traders) {
ticket -= t.entries;
if (ticket < 0) return t; // landed in this trader's range
}If nobody traded in the last hour, there are no entries — the current holder simply keeps the potato (and the next payout) until trading resumes.
The payout split
The pot is the prize wallet's balance above a fixed 2 SOL reserve (which stays behind for rent and fees). When a holder's fifteen minutes end, that pot is split three ways:
- 50% to the winner — paid straight to the wallet that was holding the potato.
- 25% buys back & burns $POTATO — swapped for $POTATO on the pool and burned, permanently shrinking supply.
- 25% rolls over — left in the wallet, so it compounds into the next round's pot.
const pot = balance - reserveSol * 1e9; // everything above 2 SOL
payWinner(outgoingHolder, pot * 0.50); // 50% -> winner
const bought = swap(pot * 0.25, SOL -> $POTATO); // 25% -> buy back...
burn(bought); // ...and burn
// remaining 25% stays in the wallet -> next potThe round loop
The worker runs rounds on the wall-clock quarter hour — :00, :15, :30, :45 — so the countdown on the site is meaningful to everyone. Each round, in order:
- Sweep fees. A final claim of all LP positions, so the pot reflects every trade up to the draw.
- Pay the outgoing holder. Balance minus the 2 SOL reserve goes to whoever held the potato this round.
- Index volume. The last hour of pool trades is turned into recency-weighted entries.
- Draw & pass. A winner is drawn and the potato is force-transferred to them by permanent delegate.
Fee claiming and payout are isolated with try/catch: a failure in either is logged and skipped rather than stalling the draw, and any unclaimed or unpaid SOL just rolls into the next round.
Data pipeline
This site is deployed serverless on Vercel and can't reach the worker process directly. They communicate through a single key-value record in Upstash KV.
potato:summary JSON every 60s and after each round.The website is strictly read-only: no private key, no write access, no ability to influence a draw. If the worker stops, the site just shows the last published snapshot.
Trust & security
Being honest about the trust model matters more than marketing it. Here is what the design enforces and what it relies on.
Enforced by the chain
- Only one potato can ever exist — mint authority is revoked.
- Holders can never move or burn it — accounts are frozen and only the freeze authority can thaw them.
- Liquidity can never be rugged — the migrated LP is permanently locked.
- Every payout and pass is on-chain and linked from the ledger for anyone to audit.
Relies on the operator
- The house wallet is a hot wallet holding the permanent delegate, freeze authority, fee-claim rights, and the prize float. It signs every game action.
- The draw runs off-chain. It uses a secure RNG and the inputs (volume, entries) are reconstructable from chain data, but the randomness itself is not an on-chain VRF.
Reference
| Parameter | Value |
|---|---|
| Round length | 15 minutes, on the quarter hour |
| Trading tax | 2%, collected in SOL, 100% to the prize |
| Fee claim cadence | every 30 seconds, all LP positions |
| Eligibility window | last 60 minutes of volume |
| Payout split | 50% winner / 25% buyback & burn / 25% rollover |
| Volume entry tiers | $1 / $2 / $4 per entry (0–15 / 15–30 / 30–60 min) |
| Holding entries | 1 permanent entry per 25,000 $POTATO held |
| Prize reserve | 2 SOL held back each round |
| Token standard | TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb |
| Pools | Meteora Dynamic Bonding Curve → DAMM v2 |
Potato Market