Overview
Mouth uses Privy for authentication and wallet management. Users sign in with their X (Twitter) account, and Privy automatically creates an embedded wallet for them on Base.
This eliminates the need for users to:
- Install MetaMask or any browser extension
- Manage private keys or seed phrases
- Bridge funds from other chains manually
Authentication Flow
Identity Mapping
When a user logs in for the first time, Mouth stores:
| Field | Source | Description |
|---|
userId | Privy | Unique Privy user ID |
xHandle | X OAuth | The user’s X handle (e.g., @cryptoguru) |
xId | X OAuth | The user’s immutable X numeric ID |
walletAddress | Privy | The embedded wallet address on Base |
createdAt | System | Registration timestamp |
We store the X numeric ID (not just the handle) because users can change their handle. The numeric ID is immutable and ensures we always map to the correct X account.
Pre-Generated Wallets (Unregistered Opponents)
When a Challenger creates a bet against an X handle that hasn’t signed up on Mouth yet, the backend uses Privy’s server-side SDK to pre-generate an embedded wallet for that user.
How It Works
Server-Side Code
Using the @privy-io/node SDK, the backend first checks if the user already exists in Privy before creating:
// 1. Check if user exists in Privy
let privyUser;
try {
privyUser = await privy.users().getByTwitterUsername({ username: 'cryptoguru' });
} catch {
// User doesn't exist in Privy
}
// 2. If not found, create with pre-generated wallet
if (!privyUser) {
privyUser = await privy.users().create({
linked_accounts: [
{
type: 'twitter_oauth',
subject: 'cryptoguru', // placeholder — ideally the real X numeric ID
username: 'cryptoguru', // X handle (without @)
name: 'cryptoguru' // Display name
}
],
wallets: [{ chain_type: 'ethereum' }]
});
}
// 3. Extract wallet address
const walletAddress = privyUser.linked_accounts
.find(a => a.type === 'wallet' || a.type === 'smart_wallet')?.address;
When @cryptoguru eventually logs in via X OAuth on Mouth, Privy recognizes their X ID and assigns the same wallet that was pre-generated. The wallet and any assets in it (or bets referencing it) are immediately accessible.
Rate Limits
Privy’s user creation endpoint has a rate limit of 240 users per minute. Implement exponential backoff starting at 1 second if you receive HTTP 429.
Embedded Wallet
Each user gets a Privy embedded wallet — a non-custodial wallet managed by Privy’s infrastructure:
- Chain: Base (Coinbase L2)
- Currency: USDC
- Key management: Privy’s secure enclave (user doesn’t see private keys)
- Transaction execution: All transactions (deposits, withdrawals, claims) are executed server-side by the Mouth backend using Privy’s wallet API with gas sponsorship — users never sign transactions in the browser
Mouth uses a server-side relay model for all on-chain operations. The backend sends transactions on behalf of users via privy.wallets().ethereum().sendTransaction() with sponsor: true.
This means:
- Users pay zero gas fees — all transactions are sponsored by Mouth via Privy’s gas sponsorship infrastructure
- No wallet popups — users never see MetaMask-style signing prompts
- Backend controls execution — the server encodes the transaction calldata, specifies the target contract, and sends it from the user’s embedded wallet using their
privyWalletId
Supported operations:
| Operation | What happens |
|---|
| Create bet | Backend approves USDC to factory (if needed), then calls factory.createBet() from user’s wallet |
| Accept bet | Backend approves USDC to bet contract, then calls bet.deposit() from user’s wallet |
| Cancel bet | Backend calls bet.withdraw() from user’s wallet |
| Claim winnings | Backend calls bet.claim() from user’s wallet |
| Withdraw USDC | Backend calls usdc.transfer() from user’s wallet to external address |
Authorization: How the Server Signs for User Wallets
Since embedded wallets are owned by the user (not the server), Privy requires explicit authorization for the backend to send transactions on their behalf. Mouth uses the authorization key + session signer pattern:
Setup (one-time):
- Generate a P256 key pair (ECDSA on prime256v1)
- Register the public key as a key quorum in the Privy Dashboard → Authorization Keys → “Register key quorum” (threshold: 1 of 1)
- Store the private key (base64-encoded PKCS8) in the backend env as
PRIVY_AUTHORIZATION_PRIVATE_KEY
- Store the quorum ID in the frontend env as
NEXT_PUBLIC_SIGNER_QUORUM_ID
Frontend (every session):
The AuthSync component calls addSessionSigners() after login to grant the server’s key quorum permission to sign for the user’s embedded wallet during this session:
const { addSessionSigners } = useSessionSigners();
addSessionSigners({
address: user.wallet.address,
signers: [{ signerId: SIGNER_QUORUM_ID }],
});
Backend (every transaction):
The sendSponsored() function passes the authorization private key in the authorization_context:
const result = await privy.wallets().ethereum().sendTransaction(walletId, {
caip2: "eip155:8453",
params: { transaction: { to, data, value: "0x0", chain_id: 8453 } },
sponsor: true,
authorization_context: {
authorization_private_keys: [env.PRIVY_AUTHORIZATION_PRIVATE_KEY],
},
});
The user_jwts authorization method does not work when Privy is the auth provider (it’s designed for external providers like Auth0/Firebase that register a JWKS endpoint). The authorization key + session signer approach is the correct pattern for Privy-authenticated users.
Resolve & Finalize (Admin Operations)
Bet resolution and finalization are not user-initiated — they’re performed by the Mouth resolver using a dedicated backend wallet:
| Operation | Signer | Method |
|---|
| Resolve bet | Resolver account (RESOLVER_PRIVATE_KEY) | Direct Viem writeContract() — pays gas from resolver wallet |
| Finalize bet | Resolver account (RESOLVER_PRIVATE_KEY) | Direct Viem writeContract() — pays gas from resolver wallet |
These are admin-only operations protected by a wallet address check in the route handlers.
Funding the Wallet
Users deposit USDC into their embedded wallet via:
- Direct transfer: Send USDC on Base to their embedded wallet address
- On-ramp: Buy USDC directly with a credit/debit card via Privy’s built-in fiat on-ramp (Coinbase Pay, MoonPay)
- Bridge: Move USDC from any chain to Base via the integrated LI.FI bridge widget
Withdrawing from the Wallet
Users can withdraw USDC from their embedded wallet to any external address at any time (for funds not locked in active bets). Withdrawals are executed server-side via POST /auth/withdraw.
USDC Approval to Factory
The first time a user creates a bet, the backend grants an infinite USDC approval (type(uint256).max) from the user’s embedded wallet to the MouthBetFactory contract. This is a one-time gasless transaction that avoids repeated approval prompts. The factory_approved field in the database tracks whether this approval has been granted.
For individual bet contracts (when accepting bets), the backend grants approval to the specific bet contract address before each deposit.
Why Privy + Embedded Wallets?
For the User
- Zero friction: Login with X, no wallet setup
- Familiar: Uses their existing X identity
- Safe: No seed phrases to lose, Privy handles key management
For the Protocol
- Identity guarantee: We know the X account behind every wallet, because Privy verified the OAuth
- PvP matching: When user A challenges
@cryptoguru, we can pre-generate a wallet and verify that the person accepting is actually @cryptoguru
- Social features: Profiles, leaderboards, and X integration all rely on verified X identity
- Compliance-ready: KYC can be layered on top of Privy if needed in the future
Security Considerations
- Privy embedded wallets are non-custodial — Mouth never has access to user private keys
- X OAuth tokens are handled by Privy and never stored by Mouth
- Smart contracts verify wallet addresses, not X handles (handles can change, wallet addresses are on-chain)
- The mapping between X identity and wallet is maintained in the Mouth database, with Privy as the source of truth