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
| Variable | Type | Description |
|---|
factory | IMouthBetFactory | MouthBetFactory address |
usdc | IERC20 | USDC token contract reference |
challenger | address | Challenger’s wallet address |
opponent | address | Opponent’s wallet for PvP (address(0) if Open PvP) |
challengerAmount | uint256 | USDC amount the Challenger deposits |
totalOpponentAmount | uint256 | Total USDC required from opponent(s), calculated from odds |
odds | uint256 | Challenger’s perceived probability (10-90, default 50) |
expirationDate | uint256 | When the prediction should be evaluated |
acceptanceDeadline | uint256 | Deadline for opponent(s) to accept (mandatory for all bet types) |
title | string | Short bet description |
disputeable | bool | If true, 48h dispute window before claim. If false, immediately finalized after resolution. |
status | BetStatus | Current state of the bet |
outcome | BetOutcome | Resolution outcome |
resolvedAt | uint256 | Timestamp when resolved |
challengerDeposited | bool | Whether Challenger has deposited |
filledAmount | uint256 | Total USDC deposited by opponent(s) so far |
opponentDeposits | mapping(address => uint256) | Each opponent’s deposit amount (Open PvP) |
opponentList | address[] | List of opponent addresses (Open PvP) |
claimableBalance | uint256 | USDC balance snapshot taken at finalization |
hasClaimed | mapping(address => bool) | Tracks which parties have claimed |
claimedCount | uint256 | Number of parties that have claimed |
cancelApprovals | mapping(address => bool) | Tracks mutual cancellation approvals |
feePercentage | uint256 | Snapshot of factory fee at creation time (basis points) |
Constants
| Constant | Value | Description |
|---|
DISPUTE_WINDOW | 48 hours | Duration of the dispute window after resolution |
MAX_OPPONENTS | 100 | Maximum 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:
| Odds | Challenger Deposits | Opponent(s) Must Deposit | Interpretation |
|---|
| 50 | $500 | $500 | Even odds |
| 20 | $100 | $400 | Challenger thinks 20% likely |
| 75 | $750 | $250 | Challenger 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):
- Caller must be the specified Opponent (Challenger cannot call — they deposit via Factory)
- Bet must be in
Pending status
- Opponent deposits
totalOpponentAmount (the _amount parameter is ignored, overridden to totalOpponentAmount)
- 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, and BetActivated events
Logic (Open PvP):
- Caller must be any Mouth user (not the Challenger)
- Bet must be in
Pending or Filling status
_amount must 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 to opponentList if new (capped at MAX_OPPONENTS = 100 unique depositors; existing depositors can top up without counting toward this limit)
- Increment
filledAmount by _amount
- Status becomes
Filling if not already
- Collect proportional fee:
fee = _amount * (challengerAmount + totalOpponentAmount) * feePercentage / (totalOpponentAmount * 10000)
- Transfer fee to treasury
- If
filledAmount == totalOpponentAmount: Status becomes Active
- 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:
- Bet must be in
Filling status
- Caller must be the Challenger, or
acceptanceDeadline must have passed
filledAmount must be > 0
- Recalculate
challengerAmount proportionally: matchedChallengerAmount = (filledAmount * challengerAmount) / totalOpponentAmount
- Return excess Challenger deposit (
challengerAmount - matchedChallengerAmount) to Challenger
- Update
challengerAmount and totalOpponentAmount to the matched values
- Status becomes
Active (fee already collected per-deposit)
- 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:
- Caller must be the Challenger or the Resolver
- Bet must be in
Pending status (no opponent has deposited yet, or Open PvP with no fills)
- Challenger must have deposited
- If called by the Resolver,
acceptanceDeadline must have passed (deserted bet cleanup)
- If
acceptanceDeadline has passed → set status to Expired, emit BetExpired
- 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.
function resolve(BetOutcome _outcome) external
Logic:
- Caller must be the resolver (checked via Factory)
- Bet must be in
Active status
block.timestamp must be at or past expirationDate
- Set outcome and
resolvedAt timestamp
- Emit
Resolved event
- If
disputeable = true: set status to Resolved (48h dispute window starts)
- 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:
- Bet must be
disputeable
- Caller must be the resolver (checked via Factory)
- Bet must be in
Resolved status
block.timestamp must be within the 48-hour dispute window (< resolvedAt + DISPUTE_WINDOW)
- Update outcome and reset
resolvedAt to 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.
function finalize() external
Logic:
- Bet must be in
Resolved status
- 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.
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):
- Bet must be in
Finalized status
- 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
Claimed event
Logic (Open PvP):
- Bet must be in
Finalized status
- 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
Claimed event
resolverClaim
Resolver-only batch claim: distributes all funds to winners in a single transaction.
function resolverClaim() external
Logic:
- Caller must be the resolver (checked via Factory)
- Bet must be in
Finalized status
- No individual
claim() must have been called yet (claimedCount == 0) — once any party claims individually, resolverClaim is blocked
- 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
- Set status to
Claimed
- 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:
- Bet must be in
Active status
- 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
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
| Function | Who Can Call |
|---|
deposit | Specified Opponent (PvP) or anyone except Challenger (Open PvP). Challenger deposits via Factory. |
activatePartial | Challenger (anytime during Filling) or anyone (after acceptance deadline) |
withdraw | Challenger (anytime while Pending) or Resolver (after acceptance deadline for deserted bets) |
resolve | Resolver only (via Factory) |
reResolve | Resolver only (via Factory), during 48h dispute window. Only for disputeable bets. |
finalize | Anyone (permissionless, just checks timing). Only for disputeable bets. |
claim | Winner only, or all parties in case of Draw (Open PvP: each opponent claims individually) |
resolverClaim | Resolver only (via Factory). Batch distributes all funds. |
mutualCancel | Challenger and Opponent(s) — all must approve |