Potato Market

Documentation

How the Hot Potato works

Potato Market is a fully autonomous on-chain game: a single NFT that no holder can move or destroy, a token whose trading tax funds a recurring prize, and a worker that draws a winner every fifteen minutes. This page explains the mechanism end to end — and the Token-2022 primitives, above all the permanent delegate authority, that make the unmovable-yet-controllable Hot Potato possible.

01 · Overview

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.

The core trick: the NFT's token accounts are frozen by default (holders can't move them) while the game wallet holds the permanent delegate and freeze authorities (the game can always thaw, move, and re-freeze). The holder has custody for show; the game has custody in fact.
02 · Architecture

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.

Solana
Token-2022 NFT, Meteora DBC / DAMM v2 pools, the house wallet.
Worker
Claims fees, runs the draw, pays out, passes the potato, publishes state.
KV + Web
Worker writes a summary to Upstash KV; this site reads it.

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.

03 · The artifact

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.

PermanentDelegateDefaultAccountState · FrozenMetadataPointer + TokenMetadata

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.

src/nft.ts — createHotPotatoNftts
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.

04 · The key primitive

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.

The permanent delegate is the house wallet. It can move the potato from anyone to anyone at any time — but it is the only address that can, and even it can't mint a second one (mint authority was revoked) or change the rules of an already-locked liquidity pool.
05 · The lock

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.

Holders genuinely own the NFT — it shows in their wallet, it's theirs on-chain — they simply can't do anything with it. Ownership without transferability is the entire point.
06 · The move

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:

src/nft.ts — passPotatots
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 account

The 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.

07 · Verification

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 holderResult
Transfer the potato to another walletRejectedError: Account is frozen
Burn the potatoRejectedError: Account is frozen
Reclaim to the house via permanent delegateSucceeded

Holders cannot escape the game, and the game can always retrieve the potato. Both halves of the custody model hold.

08 · The fuel

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.
scripts/launch-token.ts — buildCurveWithMarketCapts
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
},
Pre-bond, the prize wallet can also be topped up manually — the payout rule simply pays out the wallet's balance above a reserve, wherever that balance came from.
09 · Keeping the pot honest

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.

src/fees.ts — claimAllLpPositionFeests
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.

10 · Choosing a winner

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 ageCost per entryRelative weight
0–15 minutes$1 of volume = 1 entry
15–30 minutes$2 of volume = 1 entry
30–60 minutes$4 of volume = 1 entry
src/volume.tsts
// 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 bucket

The 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.

src/draw.ts — drawWinnerts
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.

11 · The prize

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.
src/round.ts + src/buyback.tsts
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 pot
The buyback swaps on the live pool and burns every token it receives, so each round there's constant buy pressure and a permanent supply reduction — both verifiable on-chain from the round ledger.
12 · The clock

The 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:

  1. Sweep fees. A final claim of all LP positions, so the pot reflects every trade up to the draw.
  2. Pay the outgoing holder. Balance minus the 2 SOL reserve goes to whoever held the potato this round.
  3. Index volume. The last hour of pool trades is turned into recency-weighted entries.
  4. 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.

13 · How this page gets its data

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.

Worker
Writes a full potato:summary JSON every 60s and after each round.
Upstash KV
Holds the latest snapshot: pot, holder, rounds, totals.
This site
Reads it with a read-only token; polls every 30s.

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.

14 · What's guaranteed, what isn't

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.
In short: the chain prevents the potato from being duplicated, trapped, or escaped, and prevents the liquidity from being pulled. The operator is trusted to keep the worker running and the hot wallet safe.
15 · Reference

Reference

ParameterValue
Round length15 minutes, on the quarter hour
Trading tax2%, collected in SOL, 100% to the prize
Fee claim cadenceevery 30 seconds, all LP positions
Eligibility windowlast 60 minutes of volume
Payout split50% winner / 25% buyback & burn / 25% rollover
Volume entry tiers$1 / $2 / $4 per entry (0–15 / 15–30 / 30–60 min)
Holding entries1 permanent entry per 25,000 $POTATO held
Prize reserve2 SOL held back each round
Token standardTokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
PoolsMeteora Dynamic Bonding Curve → DAMM v2