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.
function initialize(
    address _factory,
    address _challenger,
    address _opponent,
    uint256 _challengerAmount,
    uint256 _totalOpponentAmount,
    uint256 _odds,
    uint256 _expirationDate,
    uint256 _acceptanceDeadline,
    string calldata _title,
    bool _disputeable
) external initializer

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

VariableTypeDescription
factoryIMouthBetFactoryMouthBetFactory address
usdcIERC20USDC token contract reference
challengeraddressChallenger’s wallet address
opponentaddressOpponent’s wallet for PvP (address(0) if Open PvP)
challengerAmountuint256USDC amount the Challenger deposits
totalOpponentAmountuint256Total USDC required from opponent(s), calculated from odds
oddsuint256Challenger’s perceived probability (10-90, default 50)
expirationDateuint256When the prediction should be evaluated
acceptanceDeadlineuint256Deadline for opponent(s) to accept (mandatory for all bet types)
titlestringShort bet description
disputeableboolIf true, 48h dispute window before claim. If false, immediately finalized after resolution.
statusBetStatusCurrent state of the bet
outcomeBetOutcomeResolution outcome
resolvedAtuint256Timestamp when resolved
challengerDepositedboolWhether Challenger has deposited
filledAmountuint256Total USDC deposited by opponent(s) so far
opponentDepositsmapping(address => uint256)Each opponent’s deposit amount (Open PvP)
opponentListaddress[]List of opponent addresses (Open PvP)
claimableBalanceuint256USDC balance snapshot taken at finalization
hasClaimedmapping(address => bool)Tracks which parties have claimed
claimedCountuint256Number of parties that have claimed
cancelApprovalsmapping(address => bool)Tracks mutual cancellation approvals
feePercentageuint256Snapshot of factory fee at creation time (basis points)

Constants

ConstantValueDescription
DISPUTE_WINDOW48 hoursDuration of the dispute window after resolution
MAX_OPPONENTS100Maximum number of unique opponents in an Open PvP bet

Enums

enum BetStatus {
    Pending,      // Created, waiting for opponent(s)
    Filling,      // Open PvP: at least one opponent deposited, not fully filled
    Active,       // Fully funded and locked
    Resolved,     // Outcome determined, 48h dispute window active
    Finalized,    // Dispute window passed, claimable
    Claimed,      // Winner(s) claimed funds
    Cancelled,    // Challenger cancelled or mutual cancellation
    Expired       // Acceptance deadline passed with no fills
}

enum BetOutcome {
    Unresolved,
    ChallengerWins,
    OpponentWins,
    Draw
}

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:
totalOpponentAmount = challengerAmount * (100 - odds) / odds
Examples:
OddsChallenger DepositsOpponent(s) Must DepositInterpretation
50$500$500Even odds
20$100$400Challenger thinks 20% likely
75$750$250Challenger thinks 75% likely
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.
function deposit(uint256 _amount) external
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.
function activatePartial() external
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).
function withdraw() external
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.
function resolve(BetOutcome _outcome) external
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.
function reResolve(BetOutcome _outcome) external
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.
function finalize() external
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.
function claim() external
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.
function resolverClaim() external
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.
function mutualCancel() external
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

event Deposited(address indexed depositor, uint256 amount);
event FeeCollected(uint256 fee, address indexed treasury);
event Withdrawn(address indexed challenger, uint256 amount);
event BetActivated(uint256 totalPot);
event PartialActivation(uint256 filledAmount, uint256 returnedToChallenger);
event Resolved(BetOutcome outcome);
event ReResolved(BetOutcome oldOutcome, BetOutcome newOutcome);
event Finalized(uint256 claimableBalance);
event Claimed(address indexed claimant, uint256 payout);
event MutualCancelled();
event BetExpired();

Access Control

FunctionWho Can Call
depositSpecified Opponent (PvP) or anyone except Challenger (Open PvP). Challenger deposits via Factory.
activatePartialChallenger (anytime during Filling) or anyone (after acceptance deadline)
withdrawChallenger (anytime while Pending) or Resolver (after acceptance deadline for deserted bets)
resolveResolver only (via Factory)
reResolveResolver only (via Factory), during 48h dispute window. Only for disputeable bets.
finalizeAnyone (permissionless, just checks timing). Only for disputeable bets.
claimWinner only, or all parties in case of Draw (Open PvP: each opponent claims individually)
resolverClaimResolver only (via Factory). Batch distributes all funds.
mutualCancelChallenger and Opponent(s) — all must approve