Skip to main content

Overview

The Mouth backend consists of three main services:
  1. Mouth API — Core REST API serving the web app
  2. X Bot Service — Monitors X mentions and posts updates
  3. Resolution Engine — Handles bet resolution at expiration
All services share a single PostgreSQL database.

Database Schema

Core Tables

-- Users table: maps X identity to wallet
users (
  id               UUID PRIMARY KEY,
  privy_id         TEXT UNIQUE NOT NULL,
  x_handle         TEXT NOT NULL,
  x_id             TEXT UNIQUE NOT NULL,        -- immutable X numeric ID
  wallet_address   TEXT NOT NULL,               -- embedded wallet on Base
  privy_wallet_id  TEXT,                        -- Privy embedded wallet ID (used for server-side gasless transactions)
  factory_approved BOOLEAN DEFAULT FALSE,       -- true once user has infinite USDC approval to factory
  pre_registered   BOOLEAN DEFAULT FALSE,       -- true if wallet was pre-generated before user login
  created_at       TIMESTAMP DEFAULT NOW(),
  updated_at       TIMESTAMP DEFAULT NOW()
)

-- Bets table: off-chain metadata + on-chain reference
bets (
  id                UUID PRIMARY KEY,
  contract_address  TEXT UNIQUE,               -- on-chain bet contract address
  challenger_id     UUID REFERENCES users(id),
  opponent_id       UUID REFERENCES users(id), -- NULL if open bet
  title             TEXT NOT NULL,
  description       TEXT,
  category          TEXT DEFAULT 'Other',
  amount            NUMERIC NOT NULL,          -- USDC amount per side
  status            TEXT NOT NULL,             -- pending, active, resolving, resolved, cancelled, expired
  outcome           TEXT,                      -- challenger_wins, opponent_wins, draw, NULL
  expiration_date   TIMESTAMP NOT NULL,
  acceptance_deadline TIMESTAMP,
  tweet_url         TEXT,                      -- original tweet context
  created_at        TIMESTAMP DEFAULT NOW(),
  resolved_at       TIMESTAMP,
  claimed_at        TIMESTAMP
)

-- Disputes table
disputes (
  id              UUID PRIMARY KEY,
  bet_id          UUID REFERENCES bets(id),
  filed_by        UUID REFERENCES users(id),
  reason          TEXT NOT NULL,
  evidence_url    TEXT,
  status          TEXT NOT NULL,               -- open, reviewing, resolved
  resolution_note TEXT,
  created_at      TIMESTAMP DEFAULT NOW(),
  resolved_at     TIMESTAMP
)

-- Activity log for leaderboard and profiles
activity_log (
  id          UUID PRIMARY KEY,
  user_id     UUID REFERENCES users(id),
  bet_id      UUID REFERENCES bets(id),
  action      TEXT NOT NULL,                   -- created, accepted, won, lost, claimed, cancelled
  amount      NUMERIC,
  created_at  TIMESTAMP DEFAULT NOW()
)

Indexes

CREATE INDEX idx_bets_status ON bets(status);
CREATE INDEX idx_bets_challenger ON bets(challenger_id);
CREATE INDEX idx_bets_opponent ON bets(opponent_id);
CREATE INDEX idx_bets_expiration ON bets(expiration_date);
CREATE INDEX idx_activity_user ON activity_log(user_id);
CREATE INDEX idx_users_x_handle ON users(x_handle);
CREATE INDEX idx_users_x_id ON users(x_id);

API Endpoints

Auth

MethodPathDescription
POST/auth/loginAuthenticate via Privy (X OAuth)
GET/auth/meGet current user profile

Bets

MethodPathDescription
POST/betsCreate a new bet (resolves opponent X handle → wallet, pre-generates if needed)
GET/betsList bets (with filters: status, category, user)
GET/bets/:idGet bet details
POST/bets/:id/acceptAccept a bet (triggers deposit)
POST/bets/:id/cancelCancel a pending bet (Challenger only)
POST/bets/:id/claimClaim winnings (winner only)
POST/bets/:id/disputeFile a dispute
POST/bets/:id/resolveResolve a bet outcome (resolver only)
POST/bets/:id/finalizeFinalize after 48h dispute window (resolver only)

Users

MethodPathDescription
GET/users/:handleGet user public profile
GET/users/:handle/betsGet user’s bet history
GET/users/:handle/statsGet user’s win/loss stats

Leaderboard

MethodPathDescription
GET/leaderboardGet global leaderboard (sortable by profit, volume, streak)

Wallet

MethodPathDescription
POST/auth/withdrawWithdraw USDC to external address (gasless, server-side via Privy)
USDC balance is read directly on-chain by the frontend via viem — no backend endpoint needed. Wallet resolution is an internal service function (resolveXHandleToWallet), not an API endpoint.
When POST /bets receives an opponent X handle, the backend:
  1. Looks up the users table by x_handle
  2. If found → use wallet_address
  3. If not found:
    • Check if user exists in Privy via privy.users().getByTwitterUsername()
    • If not in Privy either, call Privy server SDK to create user + pre-generate embedded wallet
    • Store in users table with pre_registered = true
    • Use the new wallet_address
  4. Pass the resolved address to factory.createBet(..., opponent: walletAddress, ...)
When a pre-registered user eventually logs in via X OAuth, Privy recognizes their X ID and assigns the same wallet. The backend updates pre_registered = false on first login. See Identity & Wallets for details on Privy pre-generated wallets.

Transaction Signing

The backend uses two separate signing mechanisms depending on who initiates the transaction:

User Transactions (Gasless via Privy)

All user-initiated on-chain operations go through the sendSponsored() relay function in services/tx-relayer.ts:
OperationRouteRelay function
Create betPOST /betsrelayCreateBet()
Accept betPOST /bets/:id/acceptrelayDeposit()
Cancel betPOST /bets/:id/cancelrelayWithdraw()
Claim winningsPOST /bets/:id/claimrelayClaim()
Withdraw USDCPOST /auth/withdrawrelayTransferUSDC()
These transactions are:
  • Sent from the user’s embedded wallet (identified by privyWalletId)
  • Gas-sponsored by Mouth via Privy’s infrastructure
  • Authorized using a P256 authorization key registered as a key quorum in the Privy Dashboard, with session signer consent from the frontend
See Identity & Wallets for the full authorization flow.

Admin Transactions (Resolver Account)

Resolution and finalization are performed by a dedicated backend wallet:
OperationRouteFunction
Resolve betPOST /bets/:id/resolveresolveBetOnChain()
Finalize betPOST /bets/:id/finalizefinalizeBetOnChain()
These transactions:
  • Use a standard Viem WalletClient with the resolver’s private key (RESOLVER_PRIVATE_KEY)
  • Pay gas from the resolver wallet’s ETH balance
  • Are protected by a wallet address check (only the resolver address can call these endpoints)

Environment Variables

VariableUsed byDescription
PRIVY_AUTHORIZATION_PRIVATE_KEYUser transactionsBase64-encoded PKCS8 P256 private key for Privy authorization
RESOLVER_PRIVATE_KEYAdmin transactionsHex-encoded secp256k1 private key for the resolver account

X Bot Service

Architecture

The bot runs as a standalone Node.js service that:
  1. Polls the X API v2 for mentions of @MouthBet (or uses filtered stream if available)
  2. Processes each mention to determine the action
  3. Calls the Mouth API to create bets or fetch data
  4. Posts replies on X with bet links, confirmations, and results

Mention Processing

Incoming mention → Parse intent → Route to handler

Handlers:
- "I'll bet \$X against this" → Generate create-bet link
- "Accept" / "I'm in"       → Generate accept-bet link
- "Status"                   → Reply with bet status
- Unknown intent             → Reply with help message

Event-Driven Posts

The bot also subscribes to events from the Event Indexer:
  • BetCreated → Post bet link, tag opponent
  • BetAccepted → Post confirmation
  • BetResolved → Post result
  • BetCancelled → Post cancellation (low priority)

Resolution Engine

Architecture

The resolution engine runs as a cron job (or event-driven worker) that:
  1. Every minute: Checks for bets where expiration_date <= NOW() and status = 'active'
  2. Categorizes each bet:
    • Auto-resolvable: Has oracle-compatible resolution criteria → Fetch data, resolve
    • Manual: Requires human review → Flag for Mouth team
  3. Submits resolution transaction to the smart contract
  4. Starts the 48-hour dispute window

Oracle Integration

For auto-resolution, the engine queries:
  • Chainlink Price Feeds: Token prices (ETH, BTC, etc.)
  • Pyth Network: Additional price data, faster updates
  • On-chain queries: TVL, supply, contract state (via RPC calls)
The resolution criteria are stored as structured data in the bet’s description or as separate fields, allowing the engine to parse and evaluate automatically.