Search projects, hackathons, sponsors and tech

    ← All projects

    Whistle

    Trade player cards mid-match as goals and red cards move prices, or let your agent trade for you.

    Demo

    About

    Whistle is a live football market with a built-in rule for fairness and a built-in rule for agents. Before kick-off you mint player cards. Every card is a share of one pot, weighted by how that player performs, so a goal, an assist, a red card, a substitution or simply minutes on the pitch re-prices all 36 cards at once. At full time the pot is paid out by final score, and it empties to the last cent. Every order waits for the referee. In-play markets have always had one problem: the person in the stadium sees the goal 30 seconds before the data feed does, and trades against everyone else. In Whistle nothing fills instantly once the match is live. Every order is queued and priced 30 seconds after it's placed, after the oracle has caught up with anything you could have seen. Orders in the same tick are netted against each other and filled at one price. Fifty people selling into a red card don't race each other, because being fast buys nothing. Your agent plays it for you. Most fans won't trade for 90 minutes, so you hand an agent a playbook - Protect, Momentum or Contrarian, and a spend cap, and watch the game. Each agent is its own ENS name, agent-N.<you>.whistle.eth, with its own resolver and its own permissions. The Uniswap hook checks those permissions on ENS before every trade the agent makes. Pause writes its cap to zero; Revoke burns its roles, and its next order reverts on-chain. The records are split three ways: you set the strategy and cap, the agent writes its own action log, Whistle keeps its history, and none of them can edit the others'. A revoked agent keeps exactly enough authority to write, in its own record, that it was refused. A human approves every increase in authority. Creating an agent, raising its cap or resuming it requires a fresh World ID verification, validated on our backend before the action runs. The new agent's ENS record then carries a private "human-backed" binding anyone can resolve. Lowering a cap, pausing or revoking never waits on verification.

    How it's made

    Contracts (Solidity, Foundry, Sepolia). Market economics. Each fixture is its own market: a settlement pot, 36 player cards as ERC-20 clones, and a market-maker vault. A card's reference price is the pot × the player's expected score ÷ Σ(expected score × supply). Expected score is closed-form in the match clock (banked points plus minutes accrued plus a decaying expectation), so the pot keeps two aggregates and re-prices every card in O(1) per event rather than looping over 36 players. Invariants. Price equals mint price at kick-off; minting at the reference price leaves every other price unchanged; payouts sum to the pot. All three are fuzz-tested. Final scores are computed on-chain from the posted events, not trusted from the oracle. Uniswap v4. Every card is a v4 pool with our WhistleHook and the dynamic-fee flag. Before kick-off it trades normally. Once the match is live, beforeSwap rejects direct swaps: orders go into the hook's queue, and a keeper's tick() nets buys against sells, fills the residual pro-rata from vault inventory, and executes every fill as a real PoolManager swap. The hook returns a BeforeSwapDelta that prices it at the oracle-anchored reference price and settles against the vault's ERC-6909 claims with take/settle. The hacky part: a hook that calls swap itself gets no beforeSwap callback and silently fills against the AMM curve. We measured it on the Sepolia PoolManager: 987.16 out versus 750.00 at the hook's price. So fills go through a minimal WhistleFillRouter the hook trusts. That's written up in our FEEDBACK.md with the numbers. Fees and gas. The fee is dynamic (base 30 bps, widening after events and with distance from the reference price) and split 90/10 between liquidity and protocol. Netting is also the gas optimisation: a card whose book nets skips an entire unlock and swap. ENSv2 (Sepolia beta). whistle.eth points at our own Permissioned Registry; each user gets a subname registry; each agent gets a non-transferable subname and its own Permissioned Resolver deployed through VerifiableFactory. Why a resolver per agent. Resolver roles are scoped per resolver instance and record key, not per name. A shared resolver would let agent-1 overwrite agent-2's records, so every agent gets its own. How rights are split. Argument-scoped setText roles give you strategy, spend-cap and slippage; the agent status, last-action and pnl-live; Whistle matches-played, pnl-history and revoked-at. The registry drops its own write role in the same transaction that creates the agent. What the hook checks. isAuthorized does a live ENS read on every order: roles, expiry and the remaining cap. So editing the agent's spend-cap in any ENS interface changes what the hook allows on the next block. Other findings. The oracle's posting right is derived from the owner of oracle.whistle.eth, and that name is denied any trading role. Along the way we hit the beta's redeploy (so we pinned to the deployment tag), an EIP-7702 delegation collision on test addresses, and a linkToNode misreading — all in ENS_NOTES.md. World ID for Agents. A confidential OIDC client using private_key_jwt (RS256), PKCE and max_age=0. The backend holds the pending action, exchanges the code, validates the ID token against World's JWKS (iss, aud, nonce, state, and an auth_time inside the attempt), and only then performs the protected action with the operator key. The browser never performs it. The pairwise sub is bound to the agent as a hashed human record on ENS. Denied, cancelled and expired paths make no chain call. Integration notes are in WORLD_DEBRIEF.md. Oracle, keeper and agents (TypeScript, viem). A replay oracle posts the match from a JSON timeline: kick-off, heartbeats every five match minutes, goals, cards, substitutions, then postFinal. The keeper ticks the queue. The same logic runs as a stateless, browser-driven step function in the Next.js app, so the whole match can run from one screen: each call does one unit of work and derives everything else from chain state. The rough edges we fixed. Two events can share a minute (a heartbeat and a substitution at 65'), and the contract allows equal minutes, so a re-posted event would be applied twice. Every send is now checked against the fixture's MatchEvent history first. A lagging load-balanced RPC node once did exactly that, doubling a goal. Reads are now pinned to a block no older than the newest receipt seen. postFinal falls back to the chain's own scores if the replay and the chain ever disagree. Agent logic. It's a pure evaluate(template, state, event, board) function; the three templates are equivalence-tested against the runtime. Agents sign with keys the server manages per fixture, so creating one is a single click with no key to handle. Frontend (Next.js, Tailwind, wagmi/viem). A fixtures list; a pre-match screen where you mint, create agents and start the match on one page; a live pitch view with shirts coloured by state; agent profiles that read every ENS record live and show who wrote it; and a settlement screen that loads a build-time snapshot of settled logs so it paints instantly. Lighthouse accessibility is 100 on every screen.