Flat Bets is a prediction market on Robinhood Chain where all money is $WIF, and using the product permanently removes WIF from supply. Every stake burns 2% of itself before it enters the pool. Every market creation burns 250 WIF in full. There is no house cut, no operator revenue from the fee, and no token that the platform accumulates.
That single decision, burn on every interaction instead of taking a fee, is what makes the product a WIF product rather than a betting app that happens to accept WIF.
This post covers the design, the contract, the settlement logic, the wallet work that made it usable on a phone, and the deployment harness that lets anyone reproduce the whole run.
Repo: flatbets (contracts + web). Live on Robinhood Chain testnet, chain 46630.
1. Why parimutuel
A prediction market has three common shapes:
- Order book (Polymarket style). Best price discovery, worst cold start. A market with 12 bettors has no book, so nothing fills and the product looks broken on day one.
- LMSR / AMM (Augur, Manifold). Needs liquidity providers and a subsidy to seed every market. Someone has to fund the curve, and that someone carries inventory risk.
- Parimutuel. Everyone's money goes into one pool per side. At settlement, winners split the entire pool pro rata. No counterparty matching, no LP, no market maker.
For a token community throwing up markets around stock tokens and whatever the timeline is arguing about, parimutuel is the honest structure. A market with 3 bets is a working market. Odds are just the ratio of the two pools, which is exactly the information people want and needs no inventory to produce.
The tradeoff is real and worth stating: your payout is not locked when you bet. If the other side grows after you bet, your odds get worse. The UI solves that by showing previewPayout at the moment you sign, not a promise.
2. Architecture
Plain text version, for anyone reading this in a terminal:
wallet (injected) | v web/src/lib.ts --- chain def, WIF address, wallet kind, chain guard | v web/src/App.tsx --- board, bet slip, previewPayout, claim | +--> FlatBets.bet / claim --> WIF.transferFrom --> 2% to 0x...dEaD +--> FlatBets.resolveWithFeed --> Chainlink getRoundData(cutoff round) Two properties matter in that picture. The frontend holds no keys and no server. Everything the app knows comes from eth_call reads and the generated ABI. And the burn is a real transfer to 0x…dEaD, visible on the explorer, not an accounting entry.
3. The contract
contracts/contracts/FlatBets.sol, 435 lines, Solidity 0.8.24, no external dependencies beyond two interfaces. That was deliberate: every line a judge has to trust should be readable in one sitting.
3.1 Market storage
struct Market { address creator; address resolver; uint64 closeTime; uint64 resolveTime; Kind kind; // Manual | Feed Outcome outcome; // Open | Yes | No | Void bool lte; // false: YES wins if answer >= threshold address feed; uint8 feedDecimals; int256 threshold; int256 observedAnswer; uint64 observedAt; uint256 yesPool; uint256 noPool; uint256 burned; uint256 bettors; string question; string rule; } Two details worth copying:
-
Outcome.Openis0, the Solidity default. A market that was never touched reads as unresolved instead of needing a separatebool resolved. One less state to get wrong. - The
rulestring is stored onchain. For manual markets that is the resolution criteria, and for feed markets it names the chain and the cutoff. Bets are made against a written rule, not a vibe.
3.2 Betting, and where the burn happens
function bet(uint256 id, Outcome side, uint256 amount) external nonReentrant { Market storage m = _markets[id]; if (m.closeTime == 0) revert BadMarket(); if (m.outcome != Outcome.Open) revert AlreadyResolved(); if (block.timestamp >= m.closeTime) revert BettingClosed(); if (amount == 0) revert ZeroAmount(); if (side != Outcome.Yes && side != Outcome.No) revert BadOutcome(); uint256 burned = _pullAndBurn(msg.sender, amount); uint256 net = amount - burned; if (side == Outcome.Yes) { m.yesPool += net; yesStake[id][msg.sender] += net; } else { m.noPool += net; noStake[id][msg.sender] += net; } if (yesStake[id][msg.sender] + noStake[id][msg.sender] == net) { m.bettors += 1; } m.burned += burned; totalStaked += net; emit BetPlaced(id, msg.sender, side, net, burned); } function _pullAndBurn(address from, uint256 amount) private returns (uint256 burned) { if (amount == 0) return 0; burned = (amount * BURN_BPS) / BPS; if (!wif.transferFrom(from, address(this), amount)) revert TransferFailed(); if (burned > 0) { if (!wif.transfer(BURN_ADDRESS, burned)) revert TransferFailed(); totalBurned += burned; } } The order is load-bearing. Pull the full amount, burn the 2%, then credit net to the pool. If you credit first and burn later, or burn from the contract's own balance, an attacker can route around the burn with a token that lies about balances. Here the contract never holds more than it should, and burned is recorded per market so the UI can show it.
bettors counts unique wallets on that market, using the sum of both stakes as the "first bet from this wallet" test. It is cheap, it is accurate enough for a stat, and it avoids an extra mapping.
3.3 The payout math, in one place
function payoutOf(uint256 id, address who) public view returns (uint256) { Market storage m = _markets[id]; if (m.outcome == Outcome.Open || claimed[id][who]) return 0; uint256 yes = yesStake[id][who]; uint256 no = noStake[id][who]; if (yes == 0 && no == 0) return 0; if (m.outcome == Outcome.Void || m.yesPool == 0 || m.noPool == 0) return yes + no; if (m.outcome == Outcome.Yes) return (yes * (m.yesPool + m.noPool)) / m.yesPool; return (no * (m.yesPool + m.noPool)) / m.noPool; } Three cases: void, one side empty, and a normal win. The m.yesPool == 0 || m.noPool == 0 clause is the important one. A market where everyone bet YES has no counterparty to pay the winners, so the contract refunds instead of inventing a payout. Doing that implicitly in the win branch would make claim revert on a market nobody could settle.
Integer division truncates toward zero, so the contract can never pay out more than the pool. There is a test for exactly that: never pays out more than the pool, even with odd amounts.
The read path uses the same formula, so the number in the UI is the number you get:
function payoutOf(uint256 id, address who) public view returns (uint256) { ... } function previewPayout(uint256 id, Outcome side, uint256 amount) external view returns (uint256 net, uint256 burned, uint256 payoutIfWin) { Market storage m = _markets[id]; burned = (amount * BURN_BPS) / BPS; net = amount - burned; uint256 pool = side == Outcome.Yes ? m.yesPool : m.noPool; uint256 total = m.yesPool + m.noPool + net; payoutIfWin = pool + net == 0 ? 0 : (net * total) / (pool + net); } previewPayout simulates your own bet entering the pool before you sign it. A bet slip that shows the old odds is a lie that grows as stakes come in, so this is called on every keystroke.
3.4 Feed settlement that cannot be gamed by timing
function resolveWithFeed(uint256 id, uint80 roundId) external { Market storage m = _markets[id]; ... if (block.timestamp < m.resolveTime) revert TooEarly(); if (m.outcome != Outcome.Open) revert AlreadyResolved(); (uint80 gotId, int256 answer,, uint256 updatedAt, uint80 answeredInRound) = IPriceFeed(m.feed).getRoundData(roundId); if (gotId != roundId) revert BadFeedAnswer(); if (answer <= 0) revert BadFeedAnswer(); if (answeredInRound < roundId) revert StaleFeed(); if (updatedAt < m.resolveTime) revert TooEarly(); if (roundId > 1) { (,,, uint256 prevUpdatedAt,) = IPriceFeed(m.feed).getRoundData(roundId - 1); if (prevUpdatedAt >= m.resolveTime) revert NotFirstRound(); } m.observedAnswer = answer; m.observedAt = uint64(updatedAt); m.outcome = m.lte ? (answer <= m.threshold ? Outcome.Yes : Outcome.No) : (answer >= m.threshold ? Outcome.Yes : Outcome.No); emit MarketResolved(id, m.outcome, answer, uint64(updatedAt), msg.sender); } Two attacks die here.
Reading latestRoundData at settlement time lets whoever calls it wait for a favourable print and settle the past with hindsight. Fix: the caller has to name a round id, so settlement reads history instead of the present.
Naming an early round. The named round has to have printed at or after resolveTime, so no round that existed while betting was still open can be used. And the round before it must predate resolveTime, which pins settlement to the first print after the cutoff. Picking a later, kinder print is rejected by NotFirstRound.
The off-chain helper does not have to be clever, because the contract is strict. scripts/settestnet.ts pushes a fresh mock round at the current timestamp and settles with roundId + 1, which is by construction the first round after the cutoff. On a real feed the same call works with whatever round the aggregator published first after resolveTime.
voidMarket exists for the case where a feed breaks or a market becomes ambiguous: resolver or owner, only after closeTime, refunds both sides. A Void outcome pays yes + no and leaves the 2% burn burned, because the burn already happened onchain and pretending otherwise would break the supply accounting.
3.5 Events and views the UI leans on
function markets(uint256 from, uint256 count) external view returns (Market[] memory out) function position(uint256 id, address who) external view returns (uint256 yes, uint256 no, bool isClaimed) function stats() external view returns (uint256 markets_, uint256 staked_, uint256 burned_, uint256 claimed_, uint256 wifHeld_) The board is one markets(0, 50) call plus one stats() call, batched by viem. No indexer, no subgraph, no API key. For 50 markets that is the right engineering call, and it keeps the whole product deployable as a static site.
4. The frontend: everything interesting is in the wallet layer
web/src/lib.ts is 219 lines and exists because "connect wallet" is where a hackathon app usually dies. Three problems had to be solved.
4.1 WIF is configurable, because the testnet token is not the mainnet token
export const WIF: Address = ((import.meta.env.VITE_WIF as string) ?? "0x5173b8eA6923c2d40267890D45C46a3480954773") as Address; Hardcoding one WIF address cost a real debugging session: the app read the canonical mainnet WIF, the markets on the testnet build staked a different token, and every bet failed with "Not enough WIF in this wallet" while the wallet visibly held tokens. A token address is configuration, not a constant.
Same treatment for chain id, RPC and explorer, so one build serves mainnet, the 46630 testnet, and a local Anvil fork (VITE_RPC=http://127.0.0.1:8548).
4.2 Trust Wallet refuses chain management, so the app stopped asking
The reported bug was a dead Connect button with An internal error has occured. That string is not our code. It is the wallet's own JSON-RPC -32603 response to wallet_switchEthereumChain and wallet_addEthereumChain, passed up through the provider and rethrown by our old handler.
Trust raises that dialog for any chain request, including when the network is already configured. So the only reliable behaviour is to never call those methods on Trust:
export async function ensureChain(): Promise<boolean> { const eth = injectedProvider(); if (!eth) return false; if (onTargetChain(await injectedChainHex())) return true; const kind = walletKind(); // Trust raises its own error dialog for any chain request, even when the // network is already configured, so it gets guidance instead of RPC calls. if (kind === "trust") return false; try { await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: TARGET_CHAIN_HEX }] }); } catch { if (canManageChain(kind)) { /* wallet_addEthereumChain, then switch again */ } } return onTargetChain(await injectedChainHex()); } The function never throws. It returns false, and the UI renders the five fields needed to add the network by hand plus a Recheck button. Connecting fast and failing loudly at the action beats failing at the door: the board is readable immediately, and the bet button explains exactly what is missing.
export async function connectWallet(): Promise<{ address: Address; onChain: boolean; kind: WalletKind }> { const eth = injectedProvider(); if (!eth) throw new Error("No wallet found. Open this page inside your wallet browser, or install one."); const accounts = (await eth.request({ method: "eth_requestAccounts" })) as Address[]; const address = accounts?.[0]; if (!address) throw new Error("Wallet returned no account."); const onChain = await ensureChain(); return { address, onChain, kind: walletKind() }; } Multi-provider browsers are another papercut. When two wallets are installed, window.ethereum.providers is an array and window.ethereum is a shim. Picking [0] gives you whichever registered first, which is how a MetaMask-only code path reaches a Trust user:
const list = eth.providers as any[] | undefined; if (Array.isArray(list) && list.length > 0) { return list.find((p) => p.isTrust || p.isTrustWallet) ?? list.find((p) => p.isMetaMask) ?? list[0]; } 4.3 Bet flow, and the allowance that saves a transaction
async function place() { const wc = walletClient(account); if (!(await ensureChain())) throw new Error(`Wallet is not on Robinhood Chain (${CHAIN_ID}). ...`); const v = parseUnits(amount, 18); if (v > bal) throw new Error("Not enough WIF in this wallet."); const allowance = (await publicClient.readContract({ address: WIF, abi: WIF_ABI, functionName: "allowance", args: [account, FLATBETS], })) as bigint; if (allowance < v) { const h = await wc.writeContract({ address: WIF, abi: WIF_ABI, functionName: "approve", args: [FLATBETS, maxUint256], chain: wc.chain, account } as any); await publicClient.waitForTransactionReceipt({ hash: h }); } const hash = await wc.writeContract({ address: FLATBETS, abi: FLATBETS_ABI, functionName: "bet", args: [BigInt(selected.m.id), side, v], chain: wc.chain, account } as any); await publicClient.waitForTransactionReceipt({ hash }); setOk(`Bet placed. ${explorerTx(hash)}`); await load(); } Checking allowance before approving means repeat bettors sign one transaction, not two. First-time bettors sign approve then bet, which is unavoidable without a permit-style token.
ABI is generated from the Hardhat artifact, not hand written:
import { FLATBETS_ABI, WIF_ABI } from "./abi"; A hand-maintained ABI with a struct drift is how the board rendered six blank cards for an afternoon. Named struct fields in the generated ABI fixed it, and the generation step is now part of the build.
5. Deployment: making a script survive a thin faucet
contracts/scripts/testnet.ts does the whole environment in one command: deploy tokens and feeds, create six markets, approve, place 13 stakes from three throwaway wallets, and log every transaction hash.
Two pieces are worth stealing.
Gas preflight with a number, not a stack trace. A full run is about 40M gas across 7 deploys and ~20 transactions. The script estimates the cost up front:
const estCost = 45_000_000n * gasPrice; if (startEth < estCost) { console.warn(`ETH short: the full run needs about ${ethers.formatEther(estCost)} ETH, deployer holds ` + `${ethers.formatEther(startEth)}. Top up at https://faucet.testnet.chain.robinhood.com`); } const bettorGas = (startEth - startEth / 3n) / 3n; Bettor funding is derived from the deployer's actual balance instead of a hardcoded 0.05 ETH per wallet. The old constant passed on a fork and failed on the public testnet with want 50000000000000000, which is a confusing way to learn that your faucet drip was smaller than your assumptions.
Three ways in on token supply. The demo should never dead-end because a wallet holds the wrong amount of WIF:
const wantsMock = process.env.USE_MOCK_WIF === "1"; const hasCode = (await ethers.provider.getCode(wifAddrCandidate)) !== "0x"; if (!wif || wifBal < 1_500n * WIF_UNIT) { /* deploy MockWIF, mint 500k, log why */ } let creationFee = 250n * WIF_UNIT; let stakePct = 100n; if (wifBal < 15_000n * WIF_UNIT) { creationFee = 40n * WIF_UNIT; // scaled fee const spare = wifBal - creationFee * BigInt(MARKET_COUNT) - 3n * BETTOR_BUFFER - 100n * WIF_UNIT; stakePct = spare > 0n ? (spare * 100n) / totalDemoWif : 0n; } Full balance gets real 250 WIF fees and full-size stakes. A mid balance gets 40 WIF fees and proportionally smaller stakes. An empty wallet gets a mock token and a 500k mint. Same script, same six markets, same screenshots.
DEMO_FAST=1 collapses the schedule to close in 8 minutes and resolve in 10, which is what makes a 3 minute demo video of a settlement loop possible. WINDOW_HOURS=720 does the opposite and keeps a public board open for a month so people can actually bet on it after launch.
Every hash lands in one file, which is what the submission's "real transactions" field needs:
const send = async (what: string, p: Promise<any>) => { const tx = await p; const rc = await tx.wait(); txs.push({ what, hash: rc!.hash }); return rc; }; 6. What the 46630 run actually produced
Deployer 0x01658A41eA7f8d7af2f4Bc5855523e5B69d7d569, chain 46630, gas price 0.01 gwei.
- FlatBets
0x08cCcAfC53B7607B0310D6F048a88c7b717ad70e - WIF being staked:
0xb162b86EA11cBe2b39A7B19bBBA1a6Fa2Ab09359 - 6 markets created, 1,707.4 WIF burned, 30 transactions recorded
Testnet honesty note, because anyone reading the explorer will notice: the token above is a WIF-mirror ERC-20 deployed by the script, not RobinWifHat 0x8B00F5B2…8ed3. Chain 46630 has no WIF faucet and the deploy wallet holds zero WIF, so there was no way to stake the real token for a demo run. The contract takes the WIF address as a constructor argument and the frontend reads it from VITE_WIF; a mainnet deploy points at canonical WIF 0x5173…4773 and nothing else changes.
The mainnet path is the same script with a real WIF balance: TESTNET_WIF=0x5173…4773 and 15,000 WIF or more gets you full fees, full stakes, and market creation that burns 250 real WIF per market.
7. Tests
17 tests, npx hardhat test, all passing. They are written as behaviour, not as coverage:
- creation fee burns in full and the market records it
- 2% of every stake burns, the net is pooled
- winners split the pool pro rata
- void refunds both sides, minus the burn
- a market with an empty side refunds everyone
- feed settle at or above threshold, and below it
- a round printed before the cutoff is rejected
- a non-positive feed answer is rejected
- settlement pins to the first round after the cutoff
- timing, resolver and double-settlement guards
-
previewPayoutmatches whatclaimpays - the losing side gets nothing
- payout never exceeds the pool with odd amounts
- owner can tune the creation fee, non-owner cannot
-
claimManysweeps several resolved markets - the markets page the frontend reads returns what it expects
The two worth writing before the contract was finished were previewPayout parity and the odd-amounts ceiling. Both are the kind of bug that only shows up on someone else's money.
8. Running it
# contracts cd contracts && npm install && npx hardhat test # deploy to Robinhood Chain testnet with a funded deployer key in ../.env FLATBETS_DEPLOYER_KEY=0x... USE_MOCK_WIF=1 WINDOW_HOURS=720 \ npx hardhat run scripts/testnet.ts --network robinhoodTestnet # settle and claim once the window closes npx hardhat run scripts/settestnet.ts --network robinhoodTestnet # web cd ../web && npm install && npm run dev web/.env:
VITE_CHAIN_ID=46630 VITE_RPC=https://rpc.testnet.chain.robinhood.com VITE_EXPLORER=https://explorer.testnet.chain.robinhood.com VITE_FLATBETS_ADDRESS=0x08cCcAfC53B7607B0310D6F048a88c7b717ad70e VITE_WIF=0xb162b86EA11cBe2b39A7B19bBBA1a6Fa2Ab09359 Frontend build is a static bundle, 469.81 kB, 143.72 kB gzipped, no backend and no API keys.
9. What is missing, honestly
- A real WIF market on mainnet. The contract takes any ERC-20, the UI reads the address from env, but nobody has run it against canonical WIF with real size yet. That is the next deploy, not a design gap.
- No indexer.
markets(0, 50)is fine to a few hundred markets and then it needs an indexer or pagination in the UI. - Manual resolution is a trust step. Feed markets remove it; manual markets still name a resolver. A council or a bonded dispute window is the obvious next step.
- One chain. No bridging, no L2 story. Robinhood Chain only, because that is where the stock tokens and the community are.
10. What this is for
Prediction markets are the clearest thing a token community can do with a token that is not speculation: take a position on something checkable, and burn supply while doing it. Every market opened and every bet placed removes WIF permanently. The flywheel is not a rewards program. It is the burn, plus the reason to come back: a board of open questions settling while you watch.
If you want to build on it, the contract is 435 lines and MIT licensed. If you want to bet, the board is live on 46630 and settles on its own.
Code & more: https://www.dailybuild.xyz/project/313-flat-bets
