Overview
EachMouthBet contract is an escrow that holds USDC deposits from the Challenger and one or more Opponents, then distributes funds based on the resolution outcome.
Uses initialize() instead of a constructor, making it clone-ready for a future migration to EIP-1167 Minimal Proxy. Inherits from Initializable and ReentrancyGuardUpgradeable (OpenZeppelin v5). The contract itself is NOT upgradeable — once deployed, the rules of a bet are immutable.
The contract supports two modes:
- PvP (1v1): A specific opponent is set at creation. Only that address can accept.
- Open PvP: No specific opponent (
address(0)). Anyone can deposit, and multiple opponents can each fill a portion of the total.
Initialization
Called once by the Factory immediately after deployment. Replaces the constructor.State Machine
PvP (1v1) — Disputeable
PvP (1v1) — Non-Disputeable
Open PvP (Partially Fillable)
When
disputeable = true, the Resolved status encompasses the 48-hour dispute window. Once the window passes (or a dispute is resolved), the bet transitions to Finalized. When disputeable = false, resolve() skips directly to Finalized — no dispute window, no finalize() call needed.State Variables
Constants
Enums
Odds & Asymmetric Deposits
Theodds parameter (10-90, default 50) determines the deposit ratio between the two sides:
- At odds = 50: both sides deposit the same amount (
challengerAmount == totalOpponentAmount) - At other values: deposits are asymmetric
The 2% protocol fee is collected per-deposit — each opponent deposit triggers a proportional fee transfer to the treasury. The fee is calculated on the total pot:
fee = _depositAmount * (challengerAmount + totalOpponentAmount) * feePercentage / (totalOpponentAmount * 10000). For PvP 1v1, this happens in a single transaction when the opponent deposits. For Open PvP, each deposit triggers its share. The winner receives the entire remaining pot (both sides, net of fee), regardless of odds.
Functions
deposit
Opponent deposits USDC into the bet. The Challenger’s deposit is handled by the Factory at creation time.
- Caller must be the specified Opponent (Challenger cannot call — they deposit via Factory)
- Bet must be in
Pendingstatus - Opponent deposits
totalOpponentAmount(the_amountparameter is ignored, overridden tototalOpponentAmount) - Transfer USDC from caller to this contract (requires prior approval of the bet contract)
- Collect 2% fee on total pot:
fee = (challengerAmount + totalOpponentAmount) * feePercentage / 10000 - Transfer fee to treasury
- Status becomes
Active - Emit
Deposited,FeeCollected, andBetActivatedevents
- Caller must be any Mouth user (not the Challenger)
- Bet must be in
PendingorFillingstatus _amountmust be greater than 0 and not exceed remaining unfilled amount (totalOpponentAmount - filledAmount)- Transfer USDC from caller to this contract
- Record deposit in
opponentDeposits[caller]and add toopponentListif new (capped atMAX_OPPONENTS = 100unique depositors; existing depositors can top up without counting toward this limit) - Increment
filledAmountby_amount - Status becomes
Fillingif not already - Collect proportional fee:
fee = _amount * (challengerAmount + totalOpponentAmount) * feePercentage / (totalOpponentAmount * 10000) - Transfer fee to treasury
- If
filledAmount == totalOpponentAmount: Status becomesActive - Emit
DepositedandFeeCollectedevents (andBetActivatedevent when fully filled)
The Challenger deposits their USDC via the Factory at bet creation time (
challengerDeposited is set to true in initialize). The Challenger must approve the Factory contract, while opponents must approve the bet contract.activatePartial
Activate an Open PvP bet with partial fills. Callable by the Challenger at any time during Filling, or by anyone after the acceptance deadline.
- Bet must be in
Fillingstatus - Caller must be the Challenger, or
acceptanceDeadlinemust have passed filledAmountmust be > 0- Recalculate
challengerAmountproportionally:matchedChallengerAmount = (filledAmount * challengerAmount) / totalOpponentAmount - Return excess Challenger deposit (
challengerAmount - matchedChallengerAmount) to Challenger - Update
challengerAmountandtotalOpponentAmountto the matched values - Status becomes
Active(fee already collected per-deposit) - Emit
PartialActivationandBetActivatedevents
withdraw
Withdraw challenger deposit. Callable by the Challenger (anytime while Pending, before or after deadline) or by the Resolver (only after acceptance deadline for deserted bets).
- Caller must be the Challenger or the Resolver
- Bet must be in
Pendingstatus (no opponent has deposited yet, or Open PvP with no fills) - Challenger must have deposited
- If called by the Resolver,
acceptanceDeadlinemust have passed (deserted bet cleanup) - If
acceptanceDeadlinehas passed → set status toExpired, emitBetExpired - If called before deadline → set status to
Cancelled - Transfer USDC back to Challenger, emit
Withdrawn(emitted in all cases, regardless of timing)
resolve
Submit the bet resolution. Only callable by the Factory’s resolver.
- Caller must be the resolver (checked via Factory)
- Bet must be in
Activestatus block.timestampmust be at or pastexpirationDate- Set outcome and
resolvedAttimestamp - Emit
Resolvedevent - If
disputeable = true: set status toResolved(48h dispute window starts) - If
disputeable = false: snapshotclaimableBalance, set status toFinalized, emitFinalizedevent (no dispute window)
reResolve
Re-resolve during the 48-hour dispute window. Only callable by the resolver. Only for disputeable bets.
- Bet must be
disputeable - Caller must be the resolver (checked via Factory)
- Bet must be in
Resolvedstatus block.timestampmust be within the 48-hour dispute window (< resolvedAt + DISPUTE_WINDOW)- Update outcome and reset
resolvedAtto current timestamp (restarts the 48h window) - Emit
ReResolved(oldOutcome, newOutcome)event
reResolve is only available for disputeable bets. It allows the Mouth team to correct a resolution if a dispute is upheld. The 48-hour window resets after each re-resolution to give all parties time to review the new outcome.finalize
Finalize the bet after the dispute window. Only relevant for disputeable bets (non-disputeable bets skip directly to Finalized in resolve()). Permissionless.
- Bet must be in
Resolvedstatus - At least 48 hours must have passed since
resolvedAt - Snapshot the contract’s USDC balance into
claimableBalance - Set status to
Finalized - Emit
Finalized(claimableBalance)event
claim
Winner claims the pot.
claim function simply distributes the remaining funds in the contract (stored in claimableBalance).
Logic (PvP 1v1):
- Bet must be in
Finalizedstatus - Determine winner based on outcome
- Distribute funds:
- ChallengerWins / OpponentWins: transfer the entire remaining contract balance to the winner
- Draw: each party receives their proportional share of the remaining balance
- Set status to
Claimed - Emit
Claimedevent
- Bet must be in
Finalizedstatus - Determine winner based on outcome
- Distribute funds:
- ChallengerWins: Challenger receives the entire remaining contract balance
- OpponentWins: each opponent claims their proportional share of the remaining balance, based on their deposit ratio (
opponentDeposits[caller] / filledAmount) - Draw: each party receives their proportional share of the remaining balance
- Set status to
Claimed(after all parties have claimed) - Emit
Claimedevent
resolverClaim
Resolver-only batch claim: distributes all funds to winners in a single transaction.
- Caller must be the resolver (checked via Factory)
- Bet must be in
Finalizedstatus - No individual
claim()must have been called yet (claimedCount == 0) — once any party claims individually,resolverClaimis blocked - Distribute funds to all winners via
_distributeAll():- ChallengerWins: transfer entire
claimableBalanceto Challenger - OpponentWins: transfer proportional shares to each opponent (or full amount to single opponent in PvP)
- Draw: transfer proportional shares to all parties
- ChallengerWins: transfer entire
- Set status to
Claimed - Emit
Claimedevents for each recipient
resolverClaim is an alternative to individual claim() calls. For PvP 1v1 bets, the backend typically calls resolverClaim right after resolve() to distribute funds in one step. For Open PvP bets with many opponents, it may be safer to let opponents claim() individually to avoid out-of-gas issues.mutualCancel
Both parties agree to cancel an active bet.
- Bet must be in
Activestatus - Caller must be Challenger or Opponent (for 1v1) / any deposited opponent (for Open PvP)
- Record caller’s approval in
cancelApprovals - For PvP: both Challenger and Opponent must have approved
- For Open PvP: Challenger and all opponents must have approved
- Refund each party their remaining deposit (the 2% fee was already collected at deposit time and is not refunded)
- Set status to
Cancelled - Emit
MutualCancelledevent
Mutual cancellation returns the remaining deposits to all parties. The 2% protocol fee was already collected at deposit time and is not refunded. A single party cannot unilaterally cancel an active bet.
