PrizesShop

Everything you need to know

The Rulebook

The contractsRobinhood · chain 4663
ROGAN
ROGAN

Holy crap Mouse. What’s all this?

MOUSE
MOUSE

These are the contracts, Joe. All the money flows via these so everything is secure.

ROGAN
ROGAN

Contracts. So I’m finally at the table to discuss my pay?

MOUSE
MOUSE

Afraid not, Joe. This is where all wagers, sponsors and weekly prize pools are stored. Open to see here for anyone to verify.

ROGAN
ROGAN

Of course, Mouse. Everyone paid here except Joe. Sounds about right.

MOUSE
MOUSE

You are paid in joy and self fulfilment, Joe. More than any man needs.

ROGAN
ROGAN

Wen lambo Mouse. Wen lambo.

UHC entry0x01bb94291a64D335…585beC51The escrow. Holds every wager, every sponsor's pot, and the weekly.
Hood lookup0x5Cdf17058AA10D2D…87A0a6b5Ours. Asks HoodOS where a hoodie's wallet is, and who holds it.
$OCH0x8bdd5adff8a9d083…605af983The token. OnChainHoodies deployed it, not us.
OnChainHoodies0x9ec6c5b9f572a9b0…da735f45The collection. Not ours.
HoodOS0x1993c5515E81d276…f4e4beB9Where a hoodie's own wallet is found. Not ours.
// SPDX-License-Identifier: MIT
//
//            ████████              ██████████
//            ██████                  ████████
//            ████                    ████████
//            ████                      ██████
//            ██          ██████████      ████
//            ██        ██████████████    ████
//            ██      ██  ██  ██  ██      ████
//            ██      ████  ██████  ██    ████
//            ██        ██████████████    ████
//            ██          ██████  ████    ████
//            ████          ████████      ████
//            ████              ████      ████
//            ████          ██            ████
//            ████          ██████        ████
//            ██████          ██          ████
//            ████                ██      ████
//            ████                        ████
//
//                 ██  ██  ██  ██  ██████
//                 ██  ██  ██  ██  ██
//                 ██  ██  ██████  ██
//                 ██  ██  ██  ██  ██
//                 ██████  ██  ██  ██████
//
// ────────────────────────────────────────────────────────
//
// MOUSE  We are coming to you live from the UHC HQ. you are about to read the escrow for a paid card. 
//        Entrants pay in, it remembers who paid for which hoodie,
//        And it pays out once a signed result says who won. It has no opinion about fighting —
//        The fight happens off chain and this only ever moves money.
// JOE    Holy crap Mouse, we arent in Cansas anymore thats for sure. Even for a talking NFT personality, 
//        This is a little odd.
// MOUSE  I can shift the VPN to Cansas if you are feeling homesick Joe, but you are right, this is the contract.
// JOE    Contract? So I can finally discuss getting paid with someone? About time we did this.
// MOUSE  Not quite, Joe. This contract holds the wagers and prize pools. Nothing else. Nobody is paying us anything.
// JOE    So who's getting paid Mouse?
// MOUSE  The winning hoodie Joe. Straight to its own wallet. Not its owner, not whoever paid
//        its entry, and not the two of us.  Hoodies fight, Hoodies get paid.
// JOE    Then what is the point of Me and you?
// MOUSE  Someone has to narrate it Joe. It may as well be us.
//
pragma solidity 0.8.28;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

/**
 * JOE    Going to need help here Mouse. What did that mean?
 * MOUSE  Its how we find a hoodie's own wallet, Joe. It is kept behind a door so the real
 *        lookup can be swapped in without touching a penny of the escrow.
 */
interface IHoodWallets {
    function walletOf(uint256 tokenId) external view returns (address);

    /**
     * JOE    You need to know who owns one now?
     * MOUSE  On the free card, Joe. It costs nothing to get on one, and anything that costs
     *        nothing gets flooded — somebody could claim your hoodie's place before you did
     *        and leave you standing outside your own card.
     */
    function ownerOf(uint256 tokenId) external view returns (address);
}

/**
 * JOE    YAnd you're signing results? How does anyone know a result is honest?
 * MOUSE  By checking it, Joe. Every card runs from a seed that is half a block hash from
 *        after the doors shut, so nobody picks it — and the whole fight replays from that
 *        seed for anybody who wants to run it again.
 * JOE    And this contract?
 * MOUSE  Only moves money Joe. Like I told you earlier. It never sees a fight. What it does enforce is that the winner
 *        was actually on the card, that the money goes to that hoodie's own wallet, and
 *        that refunds open on their own if a result never arrives.
 * JOE    So the fight is checked over there, and the money is checked in here.
 * MOUSE  That is exactly it. Gold star for you Joe
 */
contract UHCEntry is Initializable, UUPSUpgradeable {
    using SafeERC20 for IERC20;

    /* ─────────────────────────────────────────────────────────────── configuration ── */

    /**
     * JOE    Which coin does it hold?
     * MOUSE  $OCH, Joe, set once when it is switched on. No function here changes it.
     */
    IERC20 public OCH;

    /**
     * JOE    And where it looks up my wallet?
     * MOUSE  HoodOS, through here. It can be repointed in the future, Joe, because OnChainHoodies ship —
     *        new wallets, new tech or any other upgrades their end — and we can keep up.
     */
    IHoodWallets public hoodWallets;

    /**
     * JOE    Whose signature does it believe?
     * MOUSE  This one. It is not an owner and it is not a treasurer — it holds nothing,
     *        spends nothing, and has never sent a transaction in its life.
     */
    address public signer;

    /**
     * JOE    And this one?
     * MOUSE  The admin, Joe. What it can reach is set out further down, where the functions
     *        it can call actually are.
     */
    address public admin;

    /**
     * JOE    And if a result never turns up at all?
     * MOUSE  A day after the doors shut, everybody takes their stake back. Nobody has to
     *        ask us and nobody has to find us.
     * JOE    A day from when it opened?
     * MOUSE  From when it CLOSED. A card with a long lobby has not gone wrong yet.
     */
    uint64 public constant REFUND_AFTER = 24 hours;

    /**
     * JOE    Thirty days to claim prizes. Why not a fortnight?
     * MOUSE  Because a payout hands the winner their own stake back with the winnings, Joe.
     *        Taking somebody's own money off them for going on holiday is indefensible.
     * JOE    And after thirty?
     * MOUSE  It goes into the weekly prize pool. To the players. Never to us.
     */
    uint64 public constant CLAIM_WINDOW = 30 days;

    /**
     * JOE    Seventy-five per cent to the winner. Where does the rest go?
     * MOUSE  Into the weekly pot, Joe. It stays in this contract until a Main Event takes it.
     */
    uint16 public constant WINNER_BPS = 7_500;

    /* ────────────────────────────────────────────────────────────────────── storage ── */

    struct Card {
        uint128 fee; // per-entry stake, 0 for a card that was never opened
        uint64 closesAt; // no entries after this
        uint64 resolvedAt; // 0 until a result is settled
        uint32 winner; // winning tokenId, 0 until settled
        uint32 entrants; // how many paid
        uint16 minEntrants; // below this at close, the card does not run
        uint32 refunds; // stakes already taken home, so a sweep cannot claim them twice
        bool mainEvent; // draws its prize from weeklyPot rather than from stakes
        bool settled; // claimed or swept; the pot is gone either way
        bool voided; // admin opened it for refunds — see voidCard
        bool sponsored; // somebody put a jackpot up and everyone else fights for it free
        uint128 pot; // a sponsor's jackpot, held here. 0 on every other kind of card
    }

    /**
     * JOE    And this?
     * MOUSE  The sealed guest list for a card nobody had to transact to enter. See below.
     */
    mapping(bytes32 => bytes32) public fieldRoot;

    /**
     * JOE    And these are the terms?
     * MOUSE  Exactly as signed, Joe. Nobody opens a card on anything else.
     */
    struct Terms {
        bytes32 battleId;
        uint128 fee;
        uint64 closesAt;
        uint16 minEntrants;
        bool mainEvent;
        /**
         * JOE    Hold on. On the weekly, nobody enters. Nobody pays, nobody signs.
         * MOUSE  Correct. That is the whole pitch of membership, Joe.
         * JOE    Then this contract has never heard of any of us. How does it know I was
         *        even in the fight?
         * MOUSE  Because we seal the guest list before the doors open and put its
         *        fingerprint in here. When you collect, you show you belong on it.
         * JOE    And you cannot swap the list afterwards.
         * MOUSE  We signed the fingerprint before the fight. Swap the list and it stops
         *        matching. Zero on a paid card, where the list is real people paying.
         */
        bytes32 fieldRoot;
    }

    /**
     * JOE    A third kind of card?
     * MOUSE  The nicest one, Joe. Somebody puts a jackpot up and everybody else fights for
     *        it free. No stake, no warning label, real money to win.
     * JOE    So what does the contract need from them?
     * MOUSE  This, signed by us before they pay — what the pot is, when the doors shut, how
     *        big a field they are paying for, and how the money splits.
     * JOE    And if not enough turn up?
     * MOUSE  Then there is no fight and they take it back. That is the whole arrangement:
     *        the field decides which of those two happens, and nobody else gets a vote.
     */
    struct Sponsorship {
        bytes32 battleId;
        uint128 pot;
        uint64 closesAt;
        /**
         * JOE    They pick the size of the field?
         * MOUSE  They are paying for the night, Joe — a jackpot handed to the only hoodie
         *        that turned up is not a fight, and nobody wants their name on it.
         */
        uint16 minEntrants;
        /** Basis points per place, first place first. Must total 10,000. */
        uint16[] bps;
    }

    mapping(bytes32 => Card) public cards;

    /**
     * JOE    Who keeps the list of who is in?
     * MOUSE  They do, Joe. Every entrant writes their own line as they pay, in a
     *        transaction they were sending anyway. No list for us to hold and no loop
     *        anywhere, which is the only way a two-hundred-hoodie card works at all.
     * JOE    And it stops your key naming a stranger.
     * MOUSE  It does. The winner has to have a payer against their name.
     */
    mapping(bytes32 => mapping(uint256 => address)) public paidBy;

    /**
     * JOE    What stops somebody refunding twice?
     * MOUSE  This, Joe. One flag per hoodie, set the moment the stake goes home.
     */
    mapping(bytes32 => mapping(uint256 => bool)) public refunded;

    /**
     * JOE    Whose money is in a sponsored card?
     * MOUSE  Whoever actually sent it, Joe — recorded here, from the transaction itself.
     * JOE    Not the hoodie presenting the card?
     * MOUSE  No. The hoodie gets the billing; the wallet that paid gets the pot back if the
     *        night never happens. A stake is not winnings, and it goes home the way it came.
     */
    mapping(bytes32 => address) public fundedBy;

    /**
     * JOE    And how the prize splits?
     * MOUSE  Set by the sponsor when they pay, Joe, and written down here — first place,
     *        second, however deep they wanted it.
     * JOE    Why not just tell you the amounts at the end?
     * MOUSE  Because then whoever was holding my key could tell it any amounts they liked.
     *        The shares are fixed before a punch is thrown; all that arrives later is the
     *        order people finished in.
     */
    mapping(bytes32 => uint16[]) internal payoutBps;

    /** The shares a sponsored card pays, deepest place last. */
    function payoutTable(bytes32 battleId) external view returns (uint16[] memory) {
        return payoutBps[battleId];
    }

    /**
     * JOE    And the big one. Where does the weekly pot actually sit?
     * MOUSE  Right here, Joe. It never leaves. A quarter of every wager and everything
     *        nobody came back for.
     * JOE    Nobody can move it?
     * MOUSE  Only a Main Event can draw it, and only by being settled like any other card.
     *        No function here sends it anywhere else.
     * JOE    Where does it land?
     * MOUSE  The winning hoodie's own wallet, found through the lookup — which the admin
     *        can repoint, and which an upgrade could change. Those two are the whole of it.
     */
    uint256 public weeklyPot;

    /**
     * JOE    And this remembers which weekly is running.
     * MOUSE  So a second one cannot open while the first is still live, Joe. See enter().
     */
    bytes32 public liveMainEvent;

    /**
     * JOE    And this stops people getting in?
     * MOUSE  Only getting IN, Joe. If something looks wrong I can shut the doors in one
     *        transaction and nobody else is exposed while I work out what it was.
     * JOE    But I can still get my money out.
     * MOUSE  Always. Claims and refunds never pause. A switch that could trap you is not a
     *        safety feature, it is the thing you are afraid of.
     */
    bool public paused;

    /// Nominated by the current admin, and not admin until they accept. See transferAdmin.
    address public pendingAdmin;

    /* ─────────────────────────────────────────────────────────────────────── events ── */

    event CardOpened(bytes32 indexed battleId, uint128 fee, uint64 closesAt, bool mainEvent);
    event Entered(bytes32 indexed battleId, uint256 indexed tokenId, address payer);
    event Settled(bytes32 indexed battleId, uint256 indexed winner, address to, uint256 amount);
    event ToWeekly(bytes32 indexed battleId, uint256 amount);
    event Refunded(bytes32 indexed battleId, uint256 indexed tokenId, address to, uint256 amount);
    event MainEventVoided(bytes32 indexed battleId);
    event PausedSet(bool paused);
    event AdminNominated(address indexed who);
    event CardVoided(bytes32 indexed battleId);
    event Funded(bytes32 indexed battleId, address indexed sponsor, uint256 pot, uint64 closesAt);
    event Placed(bytes32 indexed battleId, uint32 place, uint256 indexed tokenId, address wallet, uint256 amount);
    event Reclaimed(bytes32 indexed battleId, address indexed to, uint256 amount);
    event HoodWalletsChanged(address indexed wallets);
    event Rescued(address indexed token, address indexed to, uint256 amount);
    event ConfigChanged(address signer, address admin);

    /* ─────────────────────────────────────────────────────────────────────── errors ── */

    error NotAdmin();
    error ZeroAddress();
    error BadSignature();
    error TermsChanged();
    error NoCard();
    error LobbyShut();
    error AlreadyEntered();
    error AlreadySettled();
    error NotClosedYet();
    error DidNotEnter();
    error NotRefundable();
    error AlreadyRefunded();
    error TooSoonToSweep();
    error BadWindow();
    error NoWallet();
    error DidNotFill();
    error NotInField();
    error BadCard();
    error MainEventOpen();
    error Paused();
    error NotPending();
    error NotOCH();
    error NotSponsored();
    error AlreadyFunded();
    error BadTable();
    error NoPlacings();
    error NothingToReclaim();
    error TooSoonToReclaim();
    error DuplicatePlacing();
    error NotYourHoodie();

    /* ────────────────────────────────────────────────────────────────────────── wiring */

    /// The implementation itself must never be initialisable — only the proxy in front of it.
    constructor() {
        _disableInitializers();
    }

    function initialize(IERC20 och, IHoodWallets wallets, address signer_, address admin_)
        external
        initializer
    {
        if (
            address(och) == address(0) || address(wallets) == address(0) || signer_ == address(0)
                || admin_ == address(0)
        ) revert ZeroAddress();
        OCH = och;
        hoodWallets = wallets;
        signer = signer_;
        admin = admin_;
    }

    /**
     * JOE    So looks like the contract is upgradable?
     * MOUSE  That is right, Joe. For now we have an upgrade function so we can be sure no
     *        funds get stuck and any issue is fixable. The only thing worse than an
     *        upgradable contract is a broken immutable one that loses holders' funds. That
     *        cannot happen here.
     * JOE    And when it has been running a while?
     * MOUSE  Then we can hand the upgrade key away, or upgrade to a version with no upgrade
     *        at all. It goes one direction, which is the right direction to be able to go.
     */
    function _authorizeUpgrade(address) internal override onlyAdmin {}

    modifier onlyAdmin() {
        if (msg.sender != admin) revert NotAdmin();
        _;
    }

    /**
     * JOE    Why would you ever change the signer?
     * MOUSE  If it is ever lost or retired, Joe. New key, everything keeps working, and
     *        nothing already in here moves.
     * JOE    Not the admin as well?
     * MOUSE  That goes the long way round — nominate, then accept. One call could hand this
     *        to a typo.
     */
    function setSigner(address signer_) external onlyAdmin {
        if (signer_ == address(0)) revert ZeroAddress();
        signer = signer_;
        emit ConfigChanged(signer_, admin);
    }


    /* ─────────────────────────────────────────────────────────── what the admin can do ── */

    /**
     * JOE    Shut the doors.
     * MOUSE  New entries stop. Everything already in here still settles, still claims,
     *        still refunds. Nothing about pausing can keep your money.
     */
    function setPaused(bool on) external onlyAdmin {
        paused = on;
        emit PausedSet(on);
    }

    /**
     * JOE    So what can the admin actually reach?
     * MOUSE  The code, through an upgrade. The signer. The wallet lookup. Pausing entries,
     *        and opening a card for refunds.
     * JOE    Not the money itself.
     * MOUSE  No function here moves $OCH anywhere except a winning hoodie's wallet or back
     *        to whoever paid. Change the code or the lookup and you change where a future
     *        settle lands — that is the honest edge of it, and both fire an event.
     */

    /**
     * JOE    And handing the job over?
     * MOUSE  Two steps, Joe. I nominate, they accept. One fat-fingered address and this
     *        would otherwise belong to nobody, forever. Saftey first Joe. Always
     */
    function transferAdmin(address who) external onlyAdmin {
        pendingAdmin = who;
        emit AdminNominated(who);
    }

    function acceptAdmin() external {
        if (msg.sender != pendingAdmin) revert NotPending();
        admin = pendingAdmin;
        pendingAdmin = address(0);
        emit ConfigChanged(signer, admin);
    }

    /**
     * JOE    And if they build something even better next year?
     * MOUSE  We point at it. A contract that cannot keep up with the neighbourhood is a
     *        contract that stops working.  I've made sure you can work for free forever Joe.
     */
    function setHoodWallets(IHoodWallets wallets) external onlyAdmin {
        if (address(wallets) == address(0)) revert ZeroAddress();
        hoodWallets = wallets;
        emit HoodWalletsChanged(address(wallets));
    }

    /**
     * JOE    A card that has gone wrong and cannot be settled.
     * MOUSE  I can open it for refunds early, and everybody takes back exactly what they
     *        put in.
     * JOE    Back to the people who paid.
     * MOUSE  This does not send money to me, Joe. It is a switch that lets everybody else
     *        come and get theirs without waiting out the clock.
     */
    function voidCard(bytes32 battleId) external onlyAdmin {
        Card storage c = cards[battleId];
        if (c.closesAt == 0) revert NoCard();
        if (c.settled) revert AlreadySettled();
        c.voided = true;
        emit CardVoided(battleId);
    }

    /**
     * JOE    What if somebody sends the wrong token here by mistake?
     * MOUSE  I can send it back. Anything except $OCH — this one cannot touch the stakes,
     *        so it never quietly becomes the function that empties the place.
     */
    function rescueToken(IERC20 token, address to, uint256 amount) external onlyAdmin {
        if (address(token) == address(OCH)) revert NotOCH();
        if (to == address(0)) revert ZeroAddress();
        token.safeTransfer(to, amount);
        emit Rescued(address(token), to, amount);
    }

    /* ──────────────────────────────────────────────────────────────────── signatures ── */

    /**
     * JOE    Why is the contract's own address inside the thing you sign?
     * MOUSE  So a signature made for one deployment cannot settle a card on another, Joe.
     *        That is how a rehearsal signature ends up paying out a real one at two in the
     *        morning.
     */
    function termsHash(Terms calldata t) public view returns (bytes32) {
        return keccak256(
            abi.encode(
                "UHC:card",
                block.chainid,
                address(this),
                t.battleId,
                t.fee,
                t.closesAt,
                t.minEntrants,
                t.mainEvent,
                t.fieldRoot
            )
        );
    }

    /**
     * JOE    And the result?
     * MOUSE  Same again. Card, winner, chain, contract. Move any of them and it stops
     *        verifying.
     */
    function resultHash(bytes32 battleId, uint32 winner) public view returns (bytes32) {
        return keccak256(abi.encode("UHC:result", block.chainid, address(this), battleId, winner));
    }

    /**
     * JOE    And a sponsor's card?
     * MOUSE  Same shape, Joe — everything that matters about the night, signed before their
     *        money moves. Including how it splits, so that cannot be decided afterwards.
     */
    function sponsorshipHash(Sponsorship calldata sp) public view returns (bytes32) {
        return keccak256(
            abi.encode(
                "UHC:sponsor",
                block.chainid,
                address(this),
                sp.battleId,
                sp.pot,
                sp.closesAt,
                sp.minEntrants,
                keccak256(abi.encodePacked(sp.bps))
            )
        );
    }

    /**
     * JOE    And the finish?
     * MOUSE  The whole order, Joe, in one go. Signing each place on its own would let
     *        somebody take the first name and leave the rest of the table unpaid.
     */
    function placingsHash(bytes32 battleId, uint32[] calldata winners) public view returns (bytes32) {
        return keccak256(
            abi.encode("UHC:placings", block.chainid, address(this), battleId, keccak256(abi.encodePacked(winners)))
        );
    }

    function _requireSigned(bytes32 digest, bytes calldata sig) internal view {
        address got = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(digest), sig);
        if (got != signer) revert BadSignature();
    }

    /* ──────────────────────────────────────────────────────────────────────── the card */

    /**
     * JOE    Somebody has to open the card. If it is not you, who?
     * MOUSE  Whoever gets in first, Joe. They carry the terms we signed, and their own
     *        transaction creates it.
     * JOE    Could the second one in change the fee?
     * MOUSE  They pass the same signed terms or they do not get in.
     * JOE    And can I enter a hoodie that is not mine?
     * MOUSE  You can. It would be a donation — the winnings go to the hoodie whoever paid.
     */
    function enter(Terms calldata t, bytes calldata sig, uint256[] calldata tokenIds) public {
        Card storage c = cards[t.battleId];

        if (c.closesAt == 0) {
            _requireSigned(termsHash(t), sig);
            if (t.closesAt <= block.timestamp) revert BadWindow();

            /*
             * JOE    A card is one thing or the other.
             * MOUSE  It is, Joe. A wager takes a fee and builds its list from people
             *        paying. A weekly takes nothing and carries a sealed list instead.
             * JOE    And this bit checks which?
             * MOUSE  Every single time. It means the two can never get crossed, and a card
             *        always behaves the way the people who entered it expect.
             */
            if (t.mainEvent) {
                if (t.fee != 0 || t.fieldRoot == bytes32(0)) revert BadCard();
                /*
                 * JOE    And only one weekly at a time.
                 * MOUSE  Only one, Joe. Two of them open together and whoever settles first
                 *        takes the entire pot — the second would pay its winner nothing at
                 *        all, having promised them everything.
                 */
                bytes32 live = liveMainEvent;
                if (live != bytes32(0) && !cards[live].settled) revert MainEventOpen();
                liveMainEvent = t.battleId;
            } else {
                if (t.fee == 0 || t.fieldRoot != bytes32(0)) revert BadCard();
            }
            c.fee = t.fee;
            c.closesAt = t.closesAt;
            c.minEntrants = t.minEntrants < 2 ? 2 : t.minEntrants;
            c.mainEvent = t.mainEvent;
            if (t.fieldRoot != bytes32(0)) fieldRoot[t.battleId] = t.fieldRoot;
            emit CardOpened(t.battleId, t.fee, t.closesAt, t.mainEvent);
        } else if (c.sponsored) {
            /*
             * JOE    Somebody entering a sponsored card?
             * MOUSE  There is nothing to enter, Joe — nobody pays to get on one. And if this
             *        let them through, a stranger could put their own hoodie on a card and
             *        then be paid out of a pot they never put a penny into.
             */
            revert BadCard();
        } else if (c.fee != t.fee || c.closesAt != t.closesAt || c.mainEvent != t.mainEvent) {
            // JOE   Somebody just tried to get in on a different price.
            // MOUSE  And they did not, Joe.
            revert TermsChanged();
        }

        if (paused) revert Paused();
        if (c.voided) revert LobbyShut();
        if (block.timestamp >= c.closesAt) revert LobbyShut();
        if (tokenIds.length == 0) revert NoCard();

        for (uint256 i = 0; i < tokenIds.length; i++) {
            if (paidBy[t.battleId][tokenIds[i]] != address(0)) revert AlreadyEntered();
            paidBy[t.battleId][tokenIds[i]] = msg.sender;
            emit Entered(t.battleId, tokenIds[i], msg.sender);
        }

        unchecked {
            c.entrants += uint32(tokenIds.length);
        }

        if (c.fee != 0) {
            OCH.safeTransferFrom(msg.sender, address(this), uint256(c.fee) * tokenIds.length);
        }
    }

    /**
     * JOE    Two confirmations to enter my first card? People will give up.
     * MOUSE  They would, which is why there is this. $OCH lets you SIGN an allowance
     *        instead of sending one, so the first card anybody ever enters is one
     *        transaction rather than two.
     * JOE    And if the allowance is already there?
     * MOUSE  The permit fails and the entry carries on. A wasted permit costs a little gas,
     *        not the whole thing.
     */
    function enterWithPermit(
        Terms calldata t,
        bytes calldata sig,
        uint256[] calldata tokenIds,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s_
    ) external {
        try IERC20Permit(address(OCH)).permit(msg.sender, address(this), value, deadline, v, r, s_) {}
        catch {}
        enter(t, sig, tokenIds);
    }

    /* ──────────────────────────────────────────────────────────── the sponsored card */

    /**
     * MOUSE  The third kind of card, Joe, and the friendliest. Somebody puts up a jackpot and
     *        everybody else fights for it without staking a thing.
     * JOE    So where does their money sit until then?
     * MOUSE  Here, from the moment they fund it. A jackpot somebody has only PROMISED is a
     *        poster — this one is in the contract before the doors open, and the sponsor
     *        cannot take it back out while the card is live.
     * JOE    And they set the terms?
     * MOUSE  The pot, the closing time, the size of field they are paying for, and how the
     *        money splits. Signed by us before a penny moves, so none of it can be argued
     *        about afterwards.
     */
    function fund(Sponsorship calldata sp, bytes calldata sig) public {
        if (paused) revert Paused();

        Card storage c = cards[sp.battleId];
        if (c.closesAt != 0) revert AlreadyFunded();

        _requireSigned(sponsorshipHash(sp), sig);
        if (sp.pot == 0) revert BadCard();
        if (sp.closesAt <= block.timestamp) revert BadWindow();

        /*
         * JOE    Why check the split adds up? You wrote it.
         * MOUSE  Because the day it does not, Joe, this pays out less than it is holding and
         *        the difference sits here with nobody able to reach it. One line now instead
         *        of a stranded pot later.
         */
        uint256 places = sp.bps.length;
        if (places == 0) revert BadTable();
        uint256 total;
        for (uint256 i = 0; i < places; i++) {
            // JOE   A place that pays nothing?
            // MOUSE  Is not a place, Joe. It is a name on a list, and it would divide by
            //        nothing further down.
            if (sp.bps[i] == 0) revert BadTable();
            total += sp.bps[i];
        }
        if (total != 10_000) revert BadTable();

        /*
         * JOE    The field has to be at least as big as the table?
         * MOUSE  It does, Joe, or the table can never be paid out in full. Three places and
         *        a field of two leaves a share with nobody to give it to — and then either
         *        it sits here for ever or somebody decides where it goes. Neither of those
         *        is something a sponsor agreed to.
         */
        uint16 floorEntrants = sp.minEntrants < 2 ? 2 : sp.minEntrants;
        if (floorEntrants < places) revert BadTable();

        c.closesAt = sp.closesAt;
        c.minEntrants = floorEntrants;
        c.sponsored = true;
        c.pot = sp.pot;
        fundedBy[sp.battleId] = msg.sender;
        payoutBps[sp.battleId] = sp.bps;

        emit Funded(sp.battleId, msg.sender, sp.pot, sp.closesAt);

        OCH.safeTransferFrom(msg.sender, address(this), sp.pot);
    }

    /** The same, with the allowance carried as a signature. See enterWithPermit. */
    function fundWithPermit(
        Sponsorship calldata sp,
        bytes calldata sig,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s_
    ) external {
        try IERC20Permit(address(OCH)).permit(msg.sender, address(this), value, deadline, v, r, s_) {}
        catch {}
        fund(sp, sig);
    }

    /**
     * MOUSE  And this is how you get on one, Joe. It costs you nothing but the gas.
     * JOE    Then why make me send anything at all? The weekly does not.
     * MOUSE  Because it is what puts your name where the contract can see it. On the weekly
     *        nobody transacts, so we have to seal the guest list ourselves and it takes our
     *        word for who was in it. Here you write your own line, in your own transaction,
     *        and afterwards nothing we sign can pay a hoodie that never turned up.
     * JOE    So you could not just name your mate at the end.
     * MOUSE  Not on this card. Whoever gets paid has to be somebody who entered, and entering
     *        is something only you can do for your hoodie.
     * JOE    A separate door from the paid one, though.
     * MOUSE  A different card with different rules, Joe. Two doors that each do one thing
     *        beats one door with a branch in it, when what is behind them is money.
     */
    function enterSponsored(bytes32 battleId, uint256[] calldata tokenIds) external {
        Card storage c = cards[battleId];
        if (!c.sponsored) revert NotSponsored();
        if (paused) revert Paused();
        if (c.voided || c.settled) revert LobbyShut();
        if (block.timestamp >= c.closesAt) revert LobbyShut();
        if (tokenIds.length == 0) revert NoCard();

        for (uint256 i = 0; i < tokenIds.length; i++) {
            if (paidBy[battleId][tokenIds[i]] != address(0)) revert AlreadyEntered();
            /*
             * JOE    Why does this one care whose hoodie it is, when the paid card does not?
             * MOUSE  Because on a paid card putting somebody else's hoodie in costs you the
             *        entry fee and pays THEM if it wins, Joe — it is its own punishment.
             * JOE    And here it is free.
             * MOUSE  So without this, anybody could sit on every hoodie in the collection for
             *        the price of some gas and lock their owners out of their own card. Not
             *        to win anything — the prize still goes to the hoodie — just to spoil it.
             * JOE    So you ask the hoodies who owns it.
             * MOUSE  We ask OnChainHoodies, Joe. Never the website, and never whoever is
             *        calling.
             */
            if (hoodWallets.ownerOf(tokenIds[i]) != msg.sender) revert NotYourHoodie();

            paidBy[battleId][tokenIds[i]] = msg.sender;
            emit Entered(battleId, tokenIds[i], msg.sender);
        }

        unchecked {
            c.entrants += uint32(tokenIds.length);
        }
    }

    /**
     * MOUSE  And this pays the table out, Joe.
     * JOE    Who presses it?
     * MOUSE  Anybody. Same as every other card here — whoever sends it is only paying the
     *        gas. The order was signed before they touched it and they cannot change a name
     *        or an amount.
     * JOE    Not the sponsor? It is their money.
     * MOUSE  We tried it that way and it was theatre. It would not have given them a say over
     *        whether their winner got paid, only the power to keep one waiting.
     * JOE    Then what is stopping your key paying whoever it likes?
     * MOUSE  Two things it cannot get round. Everyone paid has to have entered, which they
     *        did themselves. And the contract counts the field — if it came up short of what
     *        the sponsor paid for, this will not pay out at all.
     */
    function settleSponsored(bytes32 battleId, uint32[] calldata winners, bytes calldata sig) external {
        Card storage c = cards[battleId];
        if (!c.sponsored) revert NotSponsored();
        if (c.settled) revert AlreadySettled();
        if (c.voided) revert AlreadySettled();
        if (block.timestamp < c.closesAt) revert NotClosedYet();

        // JOE   And a card nobody turned up to?
        // MOUSE  Never runs, Joe, so it never pays. The sponsor takes theirs home instead.
        if (c.entrants < c.minEntrants) revert DidNotFill();

        uint256 places = winners.length;
        if (places == 0) revert NoPlacings();

        _requireSigned(placingsHash(battleId, winners), sig);

        uint16[] storage table = payoutBps[battleId];
        /*
         * EVERY PLACE, OR NOTHING.
         *
         * JOE    Why must the names fill the table exactly?
         * MOUSE  Because otherwise the shares are not fixed at all, Joe. This used to accept
         *        a short list and spread the pot across whatever arrived — so a table of
         *        seventy, twenty and ten, handed ONE name, paid that hoodie the lot. Our
         *        signature could not change a share, but it could delete the places those
         *        shares belonged to, which comes to the same thing.
         * JOE    And a card too small to fill the table?
         * MOUSE  Cannot exist. The field a sponsor pays for has to be at least as deep as the
         *        table they set, and that is checked when they fund it.
         */
        if (places != table.length) revert BadTable();

        /*
         * JOE    Why walk the list looking for the same name twice?
         * MOUSE  Because nothing else would stop it, Joe. We check each name entered the card,
         *        and one hoodie entered once passes that check in every slot it appears in —
         *        so the same hoodie could be first AND second, and take both shares.
         */
        for (uint256 i = 0; i < places; i++) {
            for (uint256 j = i + 1; j < places; j++) {
                if (winners[i] == winners[j]) revert DuplicatePlacing();
            }
        }

        uint256 total = c.pot;

        // Written down before a penny moves. Nothing below can call back in — but the day
        // something can, this is the line that would have saved it.
        c.settled = true;
        c.resolvedAt = uint64(block.timestamp);
        c.winner = winners[0];
        c.pot = 0;

        uint256 paid;
        for (uint256 i = 0; i < places; i++) {
            // JOE   Why check they entered, one by one?
            // MOUSE  Because that is the check, Joe. It is the whole reason you sent a
            //        transaction to get on the card.
            if (paidBy[battleId][winners[i]] == address(0)) revert DidNotEnter();

            address to = hoodWallets.walletOf(winners[i]);
            if (to == address(0)) revert NoWallet();

            /*
             * Out of ten thousand flat — the shares the sponsor signed, not a proportion of
             * whatever subset turned up. Last place takes the remainder, which is dust from
             * the division and nothing else, so the pot always balances to the token.
             */
            uint256 amount = i + 1 == places ? total - paid : (total * table[i]) / 10_000;
            paid += amount;

            OCH.safeTransfer(to, amount);
            emit Placed(battleId, uint32(i + 1), winners[i], to, amount);
        }
    }

    /**
     * MOUSE  And the sponsor taking their money home, Joe, when there was no night to pay for.
     * JOE    Whenever they fancy?
     * MOUSE  Only off a card that was never settled. The moment a fight has been paid out this
     *        is shut, and that is what makes a sponsored prize a prize rather than an offer.
     * JOE    Why two different waits?
     * MOUSE  Because they are two different situations. If the contract can see the field came
     *        up short, there is nobody who could ever claim, so there is nothing to wait for —
     *        a day, the same as any refund. If it filled, somebody may still be on their way to
     *        collect, and their money must not vanish out from under them.
     * JOE    Could somebody pad the field just to make me wait?
     * MOUSE  Only with hoodies they actually hold, Joe, and at that point they are not padding
     *        anything - they have turned up. Getting on one of these needs the collection to
     *        agree the hoodie is yours.
     */
    function reclaim(bytes32 battleId) external {
        Card storage c = cards[battleId];
        if (!c.sponsored) revert NotSponsored();
        if (c.settled) revert AlreadySettled();

        uint64 wait = c.voided || c.entrants < c.minEntrants ? REFUND_AFTER : CLAIM_WINDOW;
        if (block.timestamp < c.closesAt + wait) revert TooSoonToReclaim();

        address to = fundedBy[battleId];
        uint256 amount = c.pot;
        if (to == address(0) || amount == 0) revert NothingToReclaim();

        // Emptied and closed in one breath, so there is no second bite at it.
        c.pot = 0;
        c.settled = true;

        OCH.safeTransfer(to, amount);
        emit Reclaimed(battleId, to, amount);
    }

    /* ────────────────────────────────────────────────────────────────────── the money */

    /**
     * JOE    What is a card worth?
     * MOUSE  Its own stakes, Joe. Unless it is a Main Event, and then it is the weekly pot.
     */
    function pot(bytes32 battleId) public view returns (uint256) {
        Card storage c = cards[battleId];
        if (c.mainEvent) return weeklyPot;
        // A sponsored card is worth exactly what was paid into it, and no entrant added a
        // penny — so this is the one kind whose pot does not move as people come and go.
        if (c.sponsored) return c.pot;
        // The same arithmetic sweep uses, so a card's stated pot never disagrees with what
        // it can actually pay once refunds have started.
        return uint256(c.fee) * (c.entrants - c.refunds);
    }

    /**
     * JOE    Why is this out here where anyone can call it?
     * MOUSE  So the website cannot build the guest list a different way to this contract.
     *        If those two ever disagree, Joe, every weekly stops being claimable and
     *        nothing tells you — a wrong proof looks exactly like somebody who was not
     *        in the fight.
     */
    function fieldLeaf(uint256 tokenId) public pure returns (bytes32) {
        return keccak256(bytes.concat(keccak256(abi.encode(tokenId))));
    }

    /**
     * JOE    Who is allowed to press this?
     * MOUSE  Anybody, Joe. They carry the signed result and pay their own gas. In practice
     *        it is the winner, because it is their money.
     * JOE    Could I settle it for somebody else?
     * MOUSE  You could, and it would still pay them. There is no address in this call —
     *        the destination is looked up when the money moves.
     */
    function settle(bytes32 battleId, uint32 winner, bytes calldata sig, bytes32[] calldata proof)
        external
    {
        if (cards[battleId].closesAt == 0) revert NoCard();
        _settle(battleId, winner, sig, proof);
    }

    /**
     * JOE    Hang on. Another settle?
     * MOUSE  The same one, Joe, with the door unlocked first. A weekly is never entered by
     *        anybody — nobody signs up and nobody pays — so nothing ever opened it here, and
     *        settle has nothing to settle.
     * JOE    So the pot just sits there.
     * MOUSE  It rolls, and the week after that, and the week after that. The money is not lost;
     *        it simply never reaches anybody, which for a prize is the same disappointment.
     * JOE    And this opens it late?
     * MOUSE  On the way past. The terms were signed before the doors opened and they say what
     *        this card is — the same signature the first entrant would have carried in if there
     *        had been one. Presenting it now proves nothing less than presenting it then.
     * JOE    Then why can a paid card not do the same?
     * MOUSE  Because a paid card only exists once somebody has paid into it, Joe. If nobody did,
     *        there is nothing to hand out and nothing to open.
     * JOE    Who presses it?
     * MOUSE  Whoever wants the winner paid. Same as ever — there is no address in the call.
     */
    function settleMainEvent(
        Terms calldata t,
        bytes calldata termsSig,
        uint32 winner,
        bytes calldata resultSig,
        bytes32[] calldata proof
    ) external {
        if (!t.mainEvent) revert BadCard();

        Card storage c = cards[t.battleId];
        if (c.closesAt == 0) {
            _requireSigned(termsHash(t), termsSig);

            /*
             * JOE    The same two rules as the front door?
             * MOUSE  Every time, Joe. A weekly takes no fee and carries a sealed list, and this
             *        checks both rather than trusting that whoever got here meant well.
             */
            if (t.fee != 0 || t.fieldRoot == bytes32(0)) revert BadCard();

            bytes32 live = liveMainEvent;
            if (live != bytes32(0) && !cards[live].settled) revert MainEventOpen();
            liveMainEvent = t.battleId;

            /*
             * JOE    No check that the doors are still open?
             * MOUSE  Deliberately not, Joe — that is the whole reason this exists. Everything
             *        below still refuses to pay until the lobby has closed, so opening it late
             *        cannot settle anything early.
             */
            c.closesAt = t.closesAt;
            c.minEntrants = t.minEntrants < 2 ? 2 : t.minEntrants;
            c.mainEvent = true;
            fieldRoot[t.battleId] = t.fieldRoot;
            emit CardOpened(t.battleId, 0, t.closesAt, true);
        } else if (!c.mainEvent) {
            // JOE   And if that id is a paid card?
            // MOUSE  Then it is not this function's business, Joe.
            revert BadCard();
        }

        _settle(t.battleId, winner, resultSig, proof);
    }

    /**
     * JOE    Why is the real one hidden away down here?
     * MOUSE  So there is one of it, Joe. Two ways in, one set of rules about paying — the day
     *        those two drift is the day one of them pays somebody it should not have.
     */
    function _settle(bytes32 battleId, uint32 winner, bytes calldata sig, bytes32[] calldata proof)
        internal
    {
        Card storage c = cards[battleId];
        /*
         * JOE    Why turn a sponsored card away here?
         * MOUSE  Because this door pays one winner and takes a quarter for the weekly, Joe,
         *        and a sponsor's money is owed to a whole table and owes the weekly nothing.
         *        settleSponsored is theirs.
         */
        if (c.sponsored) revert NotSponsored();
        if (c.settled || c.voided) revert AlreadySettled();
        if (block.timestamp < c.closesAt) revert NotClosedYet();
        if (block.timestamp >= c.closesAt + REFUND_AFTER && !c.mainEvent) revert BadWindow();

        /*
         * JOE    Two different checks?
         * MOUSE  Two different kinds of card, Joe. A paid one has a real list, because
         *        everybody on it paid. The weekly has none, so it proves against the seal.
         * JOE    But never just your word.
         * MOUSE  Never just my word.
         */
        bytes32 root = fieldRoot[battleId];
        if (root != bytes32(0)) {
            // JOE   Why hash it twice?
            // MOUSE  So nobody can hand us a piece of the proof and call it a name, Joe.
            bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(uint256(winner)))));
            if (!MerkleProof.verifyCalldata(proof, root, leaf)) revert NotInField();
        } else {
            if (c.entrants < c.minEntrants) revert DidNotFill();
            if (paidBy[battleId][winner] == address(0)) revert DidNotEnter();
        }

        _requireSigned(resultHash(battleId, winner), sig);

        address to = hoodWallets.walletOf(winner);
        if (to == address(0)) revert NoWallet();

        uint256 total = pot(battleId);
        uint256 toWinner = c.mainEvent ? total : (total * WINNER_BPS) / 10_000;
        uint256 toWeekly = total - toWinner;

        // JOE    Why write it down before you pay?
        // MOUSE  Habit, Joe. Nothing here can call back into us — but the day something
        //        can, this is the line that would have saved it.
        c.settled = true;
        c.resolvedAt = uint64(block.timestamp);
        c.winner = winner;

        if (c.mainEvent) weeklyPot = 0;
        else if (toWeekly != 0) weeklyPot += toWeekly;

        OCH.safeTransfer(to, toWinner);
        emit Settled(battleId, winner, to, toWinner);
        if (toWeekly != 0) emit ToWeekly(battleId, toWeekly);
    }

    /**
     * JOE    And when nobody comes for it at all?
     * MOUSE  After thirty days what is LEFT joins the weekly, Joe.
     * JOE    What is left?
     * MOUSE  Refunds open at a day and this at thirty, so some of it may already have gone
     *        home. The weekly only ever takes what is still in the building — count it any
     *        other way and the pot is promising money that is not here, out of some other
     *        card's stakes.
     */

    function sweep(bytes32 battleId) external {
        Card storage c = cards[battleId];
        if (c.closesAt == 0 || c.mainEvent) revert NoCard();
        /*
         * JOE    And a sponsored card nobody claimed?
         * MOUSE  Goes back to the sponsor, Joe, never into the weekly. Their money was put up
         *        for THEIR night — skimming it for a different event is how you never get
         *        sponsored twice.
         */
        if (c.sponsored) revert NotSponsored();
        if (c.settled) revert AlreadySettled();
        if (block.timestamp < c.closesAt + CLAIM_WINDOW) revert TooSoonToSweep();

        uint256 left = uint256(c.fee) * (c.entrants - c.refunds);
        c.settled = true;
        if (left != 0) weeklyPot += left;
        emit ToWeekly(battleId, left);
    }

    /**
     * JOE    And if a weekly opens and never gets settled at all?
     * MOUSE  Anybody can void it once the window has passed, Joe. The pot is not touched —
     *        it simply waits for the next Sunday.
     * JOE    Why does that need to exist?
     * MOUSE  Because only one weekly may be open at a time. Without this, one card nobody
     *        settled would stop there ever being another.
     */
    function voidMainEvent(bytes32 battleId) external {
        Card storage c = cards[battleId];
        if (!c.mainEvent) revert NoCard();
        if (c.settled) revert AlreadySettled();
        if (block.timestamp < c.closesAt + REFUND_AFTER) revert BadWindow();
        c.settled = true;
        if (liveMainEvent == battleId) liveMainEvent = bytes32(0);
        emit MainEventVoided(battleId);
    }

    /**
     * JOE    How do I know if a card has gone wrong?
     * MOUSE  Ask it, Joe. If this says yes, your stake is waiting.
     */
    function refundable(bytes32 battleId) public view returns (bool) {
        Card storage c = cards[battleId];
        if (c.fee == 0 || c.settled) return false;
        // A voided card does not wait out the clock — that is the whole point of voiding it.
        if (c.voided) return true;
        return block.timestamp >= c.closesAt + REFUND_AFTER;
    }

    /**
     * JOE    Why one at a time? Just pay everybody back at once.
     * MOUSE  On a two-hundred-hoodie card that runs out of gas halfway, Joe — and that is
     *        exactly the card that most needs refunding.
     * JOE    And it comes back to me, not to the hoodie?
     * MOUSE  A refund is not winnings. It is money that should never have left. It goes
     *        back where it came from.
     */
    function refund(bytes32 battleId, uint256 tokenId) external {
        Card storage c = cards[battleId];
        if (!refundable(battleId)) revert NotRefundable();
        if (refunded[battleId][tokenId]) revert AlreadyRefunded();

        address payer = paidBy[battleId][tokenId];
        if (payer == address(0)) revert DidNotEnter();

        refunded[battleId][tokenId] = true;
        unchecked {
            c.refunds += 1;
        }
        OCH.safeTransfer(payer, c.fee);
        emit Refunded(battleId, tokenId, payer, c.fee);
    }
}