Skip to main content

Overview

Each MouthBet 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

The odds 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
Formula:
Examples: 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.
Logic (PvP 1v1):
  1. Caller must be the specified Opponent (Challenger cannot call — they deposit via Factory)
  2. Bet must be in Pending status
  3. Opponent deposits totalOpponentAmount (the _amount parameter is ignored, overridden to totalOpponentAmount)
  4. Transfer USDC from caller to this contract (requires prior approval of the bet contract)
  5. Collect 2% fee on total pot: fee = (challengerAmount + totalOpponentAmount) * feePercentage / 10000
  6. Transfer fee to treasury
  7. Status becomes Active
  8. Emit Deposited, FeeCollected, and BetActivated events
Logic (Open PvP):
  1. Caller must be any Mouth user (not the Challenger)
  2. Bet must be in Pending or Filling status
  3. _amount must be greater than 0 and not exceed remaining unfilled amount (totalOpponentAmount - filledAmount)
  4. Transfer USDC from caller to this contract
  5. Record deposit in opponentDeposits[caller] and add to opponentList if new (capped at MAX_OPPONENTS = 100 unique depositors; existing depositors can top up without counting toward this limit)
  6. Increment filledAmount by _amount
  7. Status becomes Filling if not already
  8. Collect proportional fee: fee = _amount * (challengerAmount + totalOpponentAmount) * feePercentage / (totalOpponentAmount * 10000)
  9. Transfer fee to treasury
  10. If filledAmount == totalOpponentAmount: Status becomes Active
  11. Emit Deposited and FeeCollected events (and BetActivated event 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.
Logic:
  1. Bet must be in Filling status
  2. Caller must be the Challenger, or acceptanceDeadline must have passed
  3. filledAmount must be > 0
  4. Recalculate challengerAmount proportionally: matchedChallengerAmount = (filledAmount * challengerAmount) / totalOpponentAmount
  5. Return excess Challenger deposit (challengerAmount - matchedChallengerAmount) to Challenger
  6. Update challengerAmount and totalOpponentAmount to the matched values
  7. Status becomes Active (fee already collected per-deposit)
  8. Emit PartialActivation and BetActivated events

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).
Logic:
  1. Caller must be the Challenger or the Resolver
  2. Bet must be in Pending status (no opponent has deposited yet, or Open PvP with no fills)
  3. Challenger must have deposited
  4. If called by the Resolver, acceptanceDeadline must have passed (deserted bet cleanup)
  5. If acceptanceDeadline has passed → set status to Expired, emit BetExpired
  6. If called before deadline → set status to Cancelled
  7. 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.
Logic:
  1. Caller must be the resolver (checked via Factory)
  2. Bet must be in Active status
  3. block.timestamp must be at or past expirationDate
  4. Set outcome and resolvedAt timestamp
  5. Emit Resolved event
  6. If disputeable = true: set status to Resolved (48h dispute window starts)
  7. If disputeable = false: snapshot claimableBalance, set status to Finalized, emit Finalized event (no dispute window)

reResolve

Re-resolve during the 48-hour dispute window. Only callable by the resolver. Only for disputeable bets.
Logic:
  1. Bet must be disputeable
  2. Caller must be the resolver (checked via Factory)
  3. Bet must be in Resolved status
  4. block.timestamp must be within the 48-hour dispute window (< resolvedAt + DISPUTE_WINDOW)
  5. Update outcome and reset resolvedAt to current timestamp (restarts the 48h window)
  6. 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.
Logic:
  1. Bet must be in Resolved status
  2. At least 48 hours must have passed since resolvedAt
  3. Snapshot the contract’s USDC balance into claimableBalance
  4. Set status to Finalized
  5. Emit Finalized(claimableBalance) event

claim

Winner claims the pot.
The 2% protocol fee has already been collected at deposit time. The claim function simply distributes the remaining funds in the contract (stored in claimableBalance). Logic (PvP 1v1):
  1. Bet must be in Finalized status
  2. Determine winner based on outcome
  3. Distribute funds:
    • ChallengerWins / OpponentWins: transfer the entire remaining contract balance to the winner
    • Draw: each party receives their proportional share of the remaining balance
  4. Set status to Claimed
  5. Emit Claimed event
Logic (Open PvP):
  1. Bet must be in Finalized status
  2. Determine winner based on outcome
  3. 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
  4. Set status to Claimed (after all parties have claimed)
  5. Emit Claimed event

resolverClaim

Resolver-only batch claim: distributes all funds to winners in a single transaction.
Logic:
  1. Caller must be the resolver (checked via Factory)
  2. Bet must be in Finalized status
  3. No individual claim() must have been called yet (claimedCount == 0) — once any party claims individually, resolverClaim is blocked
  4. Distribute funds to all winners via _distributeAll():
    • ChallengerWins: transfer entire claimableBalance to Challenger
    • OpponentWins: transfer proportional shares to each opponent (or full amount to single opponent in PvP)
    • Draw: transfer proportional shares to all parties
  5. Set status to Claimed
  6. Emit Claimed events 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.
Logic:
  1. Bet must be in Active status
  2. Caller must be Challenger or Opponent (for 1v1) / any deposited opponent (for Open PvP)
  3. Record caller’s approval in cancelApprovals
  4. For PvP: both Challenger and Opponent must have approved
  5. For Open PvP: Challenger and all opponents must have approved
  6. Refund each party their remaining deposit (the 2% fee was already collected at deposit time and is not refunded)
  7. Set status to Cancelled
  8. Emit MutualCancelled event
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.

Events

Access Control