Skip to main content

Overview

The Mouth backend runs a cron job (bet-cron.ts) that executes every 60 seconds. It performs four automated tasks:
  1. Expire pending bets — withdraw USDC back to challengers
  2. Activate partial fills — activate Open PvP bets past their deadline with partial fills
  3. Sync active bets — align DB status with on-chain state
  4. Claim finalized bets — distribute winnings to winners
All on-chain operations use the resolver account (RESOLVER_PRIVATE_KEY), keeping the cron independent from Privy and user wallets.

1. Process Expired Bets

Triggers on: bets with status = pending and acceptance_deadline < now When a challenger creates a bet and no opponent accepts before the deadline, this task:
  1. Calls withdraw() on the bet contract via the resolver account
  2. The contract returns the challenger’s USDC deposit
  3. Updates the bet status to expired in the database
The resolver is authorized to call withdraw() on expired bets. The smart contract checks: require(msg.sender == challenger || isResolver) and require(block.timestamp >= acceptanceDeadline).

2. Activate Partial Fills

Triggers on: bets with status = filling and acceptance_deadline < now When an Open PvP bet has received partial fills but the acceptance deadline has passed, this task activates the bet with whatever amount has been filled:
  1. Calls activatePartial() on the bet contract via the resolver account
  2. The contract calculates the matched challenger amount proportionally
  3. Returns the unmatched challenger deposit to the challenger
  4. Activates the bet (fee was already collected per-deposit, no additional fee here)
  5. Updates the bet status to active in the database
activatePartial() is permissionless — anyone can call it. The cron uses the resolver account for convenience. The fee for each opponent deposit was already collected at deposit time.

3. Sync Active Bets

Triggers on: bets with status = active and expiration_date < now This task reads the on-chain status of expired active bets and updates the database accordingly. It handles cases where the on-chain state changed (e.g., via manual resolution) but the DB wasn’t updated yet.
On-chain statusDB updateFields set
Resolvedstatus → resolvedoutcome, resolved_at
Finalizedstatus → finalizedoutcome, resolved_at, finalized_at
Claimedstatus → claimedoutcome, claimed_at
ActiveNo changeWaiting for resolution
This task is read-only — it only reads from the chain and writes to the DB. It does not submit any on-chain transactions.

4. Claim Finalized Bets

Triggers on: bets with status = finalized Once a bet is finalized (either immediately for non-disputeable bets, or after the 48h dispute window for disputeable bets), this task automatically distributes the winnings:
  1. Calls resolverClaim() on the bet contract via the resolver account
  2. The contract distributes USDC to all winners in a single transaction
  3. Updates the bet status to claimed in the database

Payout Distribution

resolverClaim() handles all outcome types in a single call:
OutcomeDistribution
Challenger wins100% of claimable balance to challenger
Opponent wins (PvP)100% of claimable balance to opponent
Opponent wins (Open PvP)Proportional to each opponent’s deposit
DrawProportional refund to all parties
The 2% fee is already collected at deposit time (proportionally as each opponent deposits). The claimable balance is the post-fee amount.

Lifecycle Summary

This diagram shows how the three cron tasks fit into the overall bet lifecycle:
Create bet → [pending]

                ├── Opponent accepts (PvP 1v1) → [active]
                │                                    │
                ├── Partial fills (Open PvP) → [filling]
                │                                    │
                │                          🤖 activatePartialBets() → [active]
                │                                    │
                │               ┌────────────────────┘
                │               ▼
                │             [active]
                │               │
                │               ├── Resolver resolves (disputeable) → [resolved]
                │               │       │
                │               │       └── 48h passes + finalize → [finalized]
                │               │                                       │
                │               ├── Resolver resolves (non-disputeable) → [finalized]
                │               │                                            │
                │               │               ┌────────────────────────────┘
                │               │               ▼
                │               │     🤖 claimFinalizedBets() → [claimed] ✅
                │               │
                │               └── Mutual cancel → [cancelled]

                └── Deadline passes, no opponent


              🤖 processExpiredBets() → [expired] (USDC returned)

Configuration

SettingValueDescription
Interval60 secondsHow often all four tasks run
SigningResolver EOAUses RESOLVER_PRIVATE_KEY for all on-chain calls
GasResolver paysETH on Base (typically < $0.01 per tx)
Error handlingPer-betOne failed bet does not block others

Source

The cron implementation lives in backend/src/services/bet-cron.ts. It is started automatically when the backend server boots via startBetCron() in index.ts.