Escrow contract
MatchEscrow holds the two stakes of a single match and releases them exactly once: 95% to the winner, 5% to the burn address, or a full refund. There is no shared vault and nothing accumulates, so there is no standing pool to drain.
Trust model#
Three roles, each with a separate key, so that no single secret can move money on its own.
| Role | Can | Cannot |
|---|---|---|
| Operator | Lock a match, submit signed settle/refund, pay gas | Pay anyone without an oracle signature; drain the contract |
| Oracle | Sign Result(matchId, winner, pot) | Send transactions or touch funds |
| Owner | Rotate operator/oracle, pause locking and settling | Take funds; reclaim and credit withdrawal still work while paused |
| Player | Approve a stake, receive payouts, withdraw credits, reclaim after timeout | Choose the winner |
Match state machine#
matchId acts as the nonce.A match, end to end#
What the contract enforces#
| Protection | How it works |
|---|---|
| EIP-712 signed results | settle verifies an oracle signature over Result(matchId, winner, pot). The domain binds chain id and contract address, so a signature cannot be replayed elsewhere. |
| Pay-once | State moves Locked → Settled/Refunded before any transfer (checks-effects-interactions) and cannot go back. |
| Balance-delta check | On lock, the contract measures what actually arrived per transfer and requires it to equal the stake. A fee-on-transfer token would fail at lock time instead of stranding a pot later. |
| Refunds are signed too | A refund needs an oracle signature over the same struct with winner = 0, so the operator alone cannot force one. |
| Fee guardrail | Constructor requires feeBps < 10000 (live: 500 = 5%). |
| Reentrancy & pausing | OpenZeppelin ReentrancyGuard, Pausable, Ownable2Step, SafeERC20. |
| Neutral domain name | The EIP-712 name is a constructor parameter. The server must sign with the same ESCROW_DOMAIN_NAME or settlement fails (the server refuses to boot on a mismatch). |
Escape hatches#
Auto-refund
Locks older than five minutes are swept; locks left by a crashed process are refunded at startup.
reclaim()
If a match stays Locked past 3 days, anyone can return both stakes to the players. No signature needed, funds only go back to the original two.
Pull-payment credits
If a transfer to one recipient reverts, their share is credited and pulled later with withdrawCredits(). It can never wedge the match or the other player's payout.
reclaim cannot tell "server dead" from "result merely delayed", so it always refunds. A short window would let the loser of a decided-but-unsettled match reclaim and rob the winner. Real settlement lands in seconds, so 3 days is only reachable after a genuine outage.
Events#
| Event | Emitted when |
|---|---|
Locked(matchId, a, b, stake) | Both stakes are pulled in |
Settled(matchId, winner, prize, burned) | Winner paid and fee burned |
Refunded(matchId) | Refund or reclaim |
Credited(to, amount) / CreditsWithdrawn | A push payout failed and was parked, or later pulled |
OperatorChanged / OracleChanged | Role rotated by the owner |
The live burn counter on the website is built from Settled events: it only reads logs and moves no funds.
Why the 5% goes to 0x…dEaD#
The game token does not expose a burn() function, so a native burn is not possible. Sending to the dead address removes the tokens from circulation just the same: nobody holds its private key. The only cosmetic difference is that total supply does not decrease.