Everything you need to know
Holy crap Mouse. What’s all this?
These are the contracts, Joe. All the money flows via these so everything is secure.
Contracts. So I’m finally at the table to discuss my pay?
Afraid not, Joe. This is where all wagers, sponsors and weekly prize pools are stored. Open to see here for anyone to verify.
Of course, Mouse. Everyone paid here except Joe. Sounds about right.
You are paid in joy and self fulfilment, Joe. More than any man needs.
Wen lambo Mouse. Wen lambo.
// 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);
}
}