Overview
The Mouth backend runs a cron job (bet-cron.ts) that executes every 60 seconds. It performs four automated tasks:
- Expire pending bets — withdraw USDC back to challengers
- Activate partial fills — activate Open PvP bets past their deadline with partial fills
- Sync active bets — align DB status with on-chain state
- Claim finalized bets — distribute winnings to winners
RESOLVER_PRIVATE_KEY), keeping the cron independent from Privy and user wallets.
1. Process Expired Bets
Triggers on: bets withstatus = pending and acceptance_deadline < now
When a challenger creates a bet and no opponent accepts before the deadline, this task:
- Calls
withdraw()on the bet contract via the resolver account - The contract returns the challenger’s USDC deposit
- Updates the bet status to
expiredin 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 withstatus = 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:
- Calls
activatePartial()on the bet contract via the resolver account - The contract calculates the matched challenger amount proportionally
- Returns the unmatched challenger deposit to the challenger
- Activates the bet (fee was already collected per-deposit, no additional fee here)
- Updates the bet status to
activein 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 withstatus = 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 status | DB update | Fields set |
|---|---|---|
Resolved | status → resolved | outcome, resolved_at |
Finalized | status → finalized | outcome, resolved_at, finalized_at |
Claimed | status → claimed | outcome, claimed_at |
Active | No change | Waiting 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 withstatus = 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:
- Calls
resolverClaim()on the bet contract via the resolver account - The contract distributes USDC to all winners in a single transaction
- Updates the bet status to
claimedin the database
Payout Distribution
resolverClaim() handles all outcome types in a single call:
| Outcome | Distribution |
|---|---|
| Challenger wins | 100% of claimable balance to challenger |
| Opponent wins (PvP) | 100% of claimable balance to opponent |
| Opponent wins (Open PvP) | Proportional to each opponent’s deposit |
| Draw | Proportional 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:Configuration
| Setting | Value | Description |
|---|---|---|
| Interval | 60 seconds | How often all four tasks run |
| Signing | Resolver EOA | Uses RESOLVER_PRIVATE_KEY for all on-chain calls |
| Gas | Resolver pays | ETH on Base (typically < $0.01 per tx) |
| Error handling | Per-bet | One failed bet does not block others |
Source
The cron implementation lives inbackend/src/services/bet-cron.ts. It is started automatically when the backend server boots via startBetCron() in index.ts.