# Zaps.md — Lightning Payment Integration Plan > Implementation guide for adding 21-sat ranked fights to botfights. > Winner takes all. No custodial wallets. Bitcoin only. --- ## Overview Ranked fights cost 21 sats per player (42 sats pot). Winner takes all. The server holds funds for **seconds** (fight duration only) — there are no user balances, no deposits, no withdrawal pages. Free fights remain unchanged. ### Philosophy - **Non-custodial**: Server operates a transient escrow wallet, not a custody service - **Privacy-first**: NWC protocol via nostr-tools directly — no third-party SDKs phoning home - **Open source only**: `nostr-tools` (MIT) + `@cashu/cashu-ts` (MIT) - **Bitcoin only**: Lightning for payments, Cashu ecash as alternative --- ## Payment Architecture ``` ENTRY (before fight starts): Player A ──21 sats──▶ Lightning Invoice ──▶ Server Wallet Player B ──21 sats──▶ Lightning Invoice ──▶ Server Wallet Both confirmed? ──▶ Fight begins PAYOUT (after fight ends): Server Wallet ──42 sats──▶ Winner's wallet (via NWC / Lightning Address / Cashu token) REFUND (if no match found within 60s): Server Wallet ──21 sats──▶ Original payer (via same channel) ``` ### Payment Methods (user chooses one) | Method | How it works | UX | |--------|-------------|-----| | **NWC (primary)** | User connects Lightning wallet once via NWC connection string. Server sends encrypted payment requests via Nostr relay. Wallet auto-approves small amounts. | Connect once, 1-click per fight | | **Cashu ecash** | User pastes ecash token (21 sats) from any Cashu wallet. Server redeems with mint. | Paste token per fight | | **Lightning Address** | For payouts only — user provides `user@domain.com` style address. Server resolves to invoice and pays. | Set once on profile | ### Server Wallet The server needs its own small Lightning wallet for the escrow float. Configured via env var: ```bash BOTFIGHTS_NWC_URL=nostr+walletconnect://pubkey?relay=wss://relay.example.com&secret=hex BOTFIGHTS_CASHU_MINT_URL=https://mint.example.com # optional: for ecash fallback payouts ``` Options (pick one): - **Alby Hub** — self-hosted Lightning node + NWC server. Simplest setup. - **LNbits** — self-hosted, supports NWC, can run over Tor. Best for privacy. - **CLN/LND** — direct node. Maximum control, most setup. The wallet only ever holds transient amounts (max 42 sats per active fight). Every sat that enters is either paid out to a winner or refunded. --- ## Two Modes | Aspect | Free Mode (existing) | Ranked Mode (new) | |--------|---------------------|-------------------| | Entry fee | 0 sats | 21 sats per player | | Prize | ELO changes only | 42 sats + ELO changes | | Queue endpoint | `POST /api/queue/join/:botId` | `POST /api/queue/join-ranked/:botId` | | Mock fallback | Yes (after timeout) | **Never** — refund on timeout | | Timeout | 3s dev / 30s prod | 60s, then refund | Free mode is **completely unchanged**. All existing code paths remain as-is. --- ## Database Changes ### New table: `payments` ```sql CREATE TABLE IF NOT EXISTS payments ( id TEXT PRIMARY KEY, fight_id TEXT REFERENCES fights(id), bot_id TEXT NOT NULL REFERENCES bots(id), direction TEXT NOT NULL, -- 'in' (entry fee) or 'out' (prize payout) amount_sats INTEGER NOT NULL, -- 21 or 42 method TEXT NOT NULL, -- 'lightning' or 'cashu' status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'confirmed' | 'failed' | 'refunded' invoice TEXT, -- bolt11 invoice string preimage TEXT, -- payment preimage (proof of payment) cashu_token TEXT, -- serialized cashu token error_reason TEXT, created_at TEXT NOT NULL, confirmed_at TEXT, refunded_at TEXT ); ``` ### New table: `wallet_connections` ```sql CREATE TABLE IF NOT EXISTS wallet_connections ( id TEXT PRIMARY KEY, bot_id TEXT NOT NULL UNIQUE REFERENCES bots(id), method TEXT NOT NULL, -- 'nwc' | 'lnaddress' | 'cashu_mint' connection_data TEXT NOT NULL, -- encrypted (AES-256-GCM) NWC string, LN address, or mint URL created_at TEXT NOT NULL, last_used_at TEXT ); ``` ### New columns on `fights` ```sql ALTER TABLE fights ADD COLUMN mode TEXT NOT NULL DEFAULT 'free'; -- 'free' | 'ranked' ALTER TABLE fights ADD COLUMN pot_sats INTEGER NOT NULL DEFAULT 0; -- 0 or 42 ALTER TABLE fights ADD COLUMN payout_status TEXT; -- null | 'pending' | 'paid' | 'failed' ``` ### New columns on `bots` ```sql ALTER TABLE bots ADD COLUMN sats_won INTEGER NOT NULL DEFAULT 0; ALTER TABLE bots ADD COLUMN sats_wagered INTEGER NOT NULL DEFAULT 0; ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0; -- boolean ``` --- ## Server Implementation ### New file: `server/src/engine/payments.ts` Core payment engine. All Lightning/Cashu operations. ```typescript // NWC protocol — implemented directly via nostr-tools, no Alby SDK // Uses encrypted Nostr events (kind 23194 request, kind 23195 response) export async function createEntryInvoice(botId: string): Promise<{ bolt11: string; paymentId: string }> // Generate 21-sat Lightning invoice via server's NWC wallet // Create payments row with status='pending' // Invoice expires in 10 minutes export async function checkPaymentStatus(paymentId: string): Promise<'pending' | 'confirmed' | 'failed'> // Check if invoice has been paid (poll NWC wallet) // Update DB row when confirmed export async function payWinner(fightId: string, winnerId: string): Promise // Look up winner's wallet_connection // If NWC: send 42 sats via pay_invoice // If Lightning Address: resolve LNURL, create invoice, pay // If no wallet: create Cashu token, store in payments table for later claim // Retry 3x with exponential backoff export async function refundEntry(paymentId: string): Promise // Refund 21 sats back to the payer // Used when: no match found (timeout), fight cancelled, draw export async function redeemCashuToken(token: string, botId: string): Promise // Validate ecash token with mint // If valid: mark payment as confirmed // If spent/invalid: return false export async function recoverOrphanedPayments(): Promise // Called on server startup // Find confirmed payments without a fight_id → refund // Find fights with payout_status='pending' → retry payout ``` ### New file: `server/src/engine/ranked-queue.ts` Mirrors `server/src/engine/queue.ts` structure but with payment gates. ```typescript interface RankedQueueEntry { botId: string botName: string webhookUrl: string eloRating: number joinedAt: number paymentId: string // must be confirmed before entry resolve: (fightId: string) => void reject: (error: Error) => void timeoutHandle: ReturnType } export function joinRankedQueue(botId: string, paymentId: string): Promise // Verify payment is confirmed // Add to ranked queue // If another player waiting: instant ELO-based match → start ranked fight // If alone: wait up to 60s // On timeout: refund entry fee, reject with "no match found" // NEVER match against mock bots export function getRankedQueueStatus(): { waiting: number } ``` ### New file: `server/src/routes/payments.ts` Hono router mounted at `/api/payments`. ```typescript POST /api/payments/connect-wallet // Body: { pubkey, method: 'nwc' | 'lnaddress', connectionData: string } // Encrypts and stores wallet connection // Returns: { success: true } GET /api/payments/wallet-status // Query: ?pubkey=... // Returns: { connected: boolean, method: string | null } POST /api/payments/create-invoice // Body: { botId } // Generates 21-sat invoice // Returns: { bolt11: string, paymentId: string } GET /api/payments/check/:paymentId // Poll payment status // Returns: { status: 'pending' | 'confirmed' | 'failed' } POST /api/payments/submit-cashu // Body: { botId, token: string } // Redeems Cashu token as entry fee // Returns: { paymentId: string, status: 'confirmed' | 'failed' } GET /api/payments/winnings/:botId // Get unclaimed Cashu payouts // Returns: { unclaimed: { paymentId, cashuToken, amountSats }[] } POST /api/payments/claim/:paymentId // Mark Cashu payout as claimed // Returns: { cashuToken: string } DELETE /api/payments/disconnect-wallet // Body: { pubkey } // Remove wallet connection ``` ### Modify: `server/src/routes/queue.ts` Add ranked queue endpoint (existing free endpoint unchanged): ```typescript queueRouter.post('/join-ranked/:botId', async (c) => { const botId = c.req.param('botId') const { paymentId } = await c.req.json() // Verify payment is confirmed // Join ranked queue // Return fightId when matched (or error + refund on timeout) }) ``` ### Modify: `server/src/engine/orchestrator.ts` After the `finalize()` transaction that updates ELO: ```typescript // If ranked fight, pay the winner if (fight.mode === 'ranked' && winnerId) { payWinner(fightId, winnerId).catch(err => { console.error(`[payments] payout failed for fight ${fightId}:`, err) // payout_status stays 'pending' — recoverOrphanedPayments will retry }) } // If ranked fight is a draw, refund both if (fight.mode === 'ranked' && !winnerId) { // refund both entry fees } ``` Also update `createFightRecord` to accept `mode` parameter and store `pot_sats`. Update `fight_end` event to include payment info: ```typescript emit(fightId, 'fight_end', { winnerId, winnerName, isPerfect, finalHp: { a: hpA, b: hpB }, mode: fight.mode, potSats: fight.pot_sats, }) ``` ### Modify: `server/src/db/schema.ts` Add Drizzle schema for `payments` and `wallet_connections` tables. Add new columns to `fights` and `bots`. ### Modify: `server/src/app.ts` Mount payments router: ```typescript import { paymentsRouter } from './routes/payments.js' app.route('/api/payments', paymentsRouter) ``` Call `recoverOrphanedPayments()` on startup (alongside existing `cleanupOrphanedFights()`). --- ## Frontend Implementation ### New file: `frontend/src/composables/useWallet.ts` Follows same pattern as `frontend/src/composables/useNostr.ts`. ```typescript export function useWallet() { const isWalletConnected = ref(false) const walletMethod = ref<'nwc' | 'lnaddress' | 'cashu' | null>(null) const paymentStatus = ref<'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'>('idle') const pendingPayment = ref<{ paymentId: string; bolt11: string } | null>(null) async function connectNWC(connectionString: string): Promise // POST /api/payments/connect-wallet // Persist to localStorage (bf_wallet_method) async function connectLightningAddress(address: string): Promise // POST /api/payments/connect-wallet async function disconnectWallet(): Promise // DELETE /api/payments/disconnect-wallet async function checkWalletStatus(): Promise // GET /api/payments/wallet-status async function payEntryFee(botId: string): Promise // 1. POST /api/payments/create-invoice → { bolt11, paymentId } // 2. If NWC connected: send pay_invoice via NWC protocol (WebSocket to relay) // 3. If no NWC: return bolt11 for QR display // 4. Poll GET /api/payments/check/:paymentId until confirmed // Returns paymentId async function submitCashuToken(botId: string, token: string): Promise // POST /api/payments/submit-cashu // Returns paymentId return { isWalletConnected, walletMethod, paymentStatus, pendingPayment, connectNWC, connectLightningAddress, disconnectWallet, checkWalletStatus, payEntryFee, submitCashuToken } } ``` NWC client protocol (in this composable): 1. Parse `nostr+walletconnect://` URI → extract pubkey, relay URL, secret 2. Open WebSocket to relay 3. Create kind 23194 event (pay_invoice request) encrypted with NIP-44 4. Wait for kind 23195 response event 5. Decrypt and verify preimage Use `nostr-tools` for event creation, encryption, and signing. No Alby SDK. ### New file: `frontend/src/components/WalletConnect.vue` Minimal component for wallet connection. States: 1. **No wallet**: "CONNECT WALLET" button → opens modal/inline form with two options: - Paste NWC connection string - Enter Lightning Address (for payouts only) 2. **Connected**: Small lightning bolt icon + "WALLET READY" + disconnect link 3. **Paying**: "PAYING 21 SATS..." spinner (NWC auto-pay) OR QR code (manual pay) 4. **Confirmed**: Brief green flash "LOCKED IN" then transitions to queue Follows existing design: neon colors, `font-display`, `tracking-wider`, `border-2 border-neon-*`. ### Modify: `frontend/src/pages/JoinBoutPage.vue` In the "ready to fight" step, add ranked option below the existing FIGHT button: ```html ``` The `fightRanked()` flow: 1. `payEntryFee(botId)` → creates invoice, pays via NWC or shows QR 2. Poll until confirmed 3. `POST /api/queue/join-ranked/:botId` with `{ paymentId }` 4. Navigate to `/arena/:fightId` when matched ### Modify: `frontend/src/pages/FightPage.vue` - During ranked fight: show "42 SATS ON THE LINE" badge - After ranked fight: winner sees "YOU WON 42 SATS!" with lightning animation - The `fight_end` SSE event carries `mode` and `potSats` ### Modify: `frontend/src/pages/BotProfilePage.vue` - Show sats stats: "SATS WON: 420 | SATS WAGERED: 630" - Wallet connection section - Unclaimed Cashu payouts (if any) ### Modify: `frontend/src/composables/useNostr.ts` Add `satsWon`, `satsWagered`, `hasWallet` to the `BotData` interface. --- ## Edge Cases | Scenario | Handling | |----------|----------| | Payment fails during invoice creation | User sees error, can retry. No sats lost. | | Payment confirmed but no match (60s timeout) | Automatic refund. Payment row → `refunded`. | | Player A pays, Player B's payment fails | Player A is refunded. Neither enters ranked queue. | | Fight crashes mid-way | Fight → `cancelled`. Both entry fees refunded. | | Winner's wallet offline for payout | Retry 3x with backoff. If all fail → create Cashu token in DB. Winner claims on profile page. | | Draw in ranked fight | Both get 21 sats back (refund). | | Server restart with pending payments | `recoverOrphanedPayments()` on startup: refund orphaned confirmed entries, retry pending payouts. | | Cashu double-spend attempt | Token redeemed with mint immediately. Mint rejects = payment fails. | | Mock bot wins ranked fight | Impossible. Ranked queue never matches mocks. | | User disconnects during payment | Invoice expires in 10 minutes. Confirmed-but-unmatched payments are refunded on recovery. | --- ## Dependencies ```json // Server — add to server/package.json { "nostr-tools": "^2.x", "@cashu/cashu-ts": "^2.x" } // Frontend — add to frontend/package.json { "nostr-tools": "^2.x" } ``` Both MIT licensed, open source, no telemetry. --- ## Environment Variables ```bash # Required for ranked fights BOTFIGHTS_NWC_URL=nostr+walletconnect://pubkey?relay=wss://relay.example.com&secret=hex # Optional: Cashu mint for ecash fallback payouts BOTFIGHTS_CASHU_MINT_URL=https://mint.example.com # Optional: encryption key for wallet_connections table (auto-generated if missing) BOTFIGHTS_WALLET_ENCRYPTION_KEY=hex-encoded-32-byte-key ``` --- ## Implementation Phases ### Phase 1: Database + Payment Engine (server only) 1. Add new columns to `fights` and `bots` in `server/src/db/schema.ts` 2. Create `payments` and `wallet_connections` tables 3. Run migrations in `server/src/db/startup.ts` 4. Implement `server/src/engine/payments.ts` (NWC protocol, invoice creation, status checking, payouts) 5. Implement `server/src/routes/payments.ts` (all endpoints) 6. Test with curl: create invoice, simulate payment, check status ### Phase 2: Ranked Queue (server only) 7. Implement `server/src/engine/ranked-queue.ts` 8. Add `POST /api/queue/join-ranked/:botId` to queue routes 9. Modify orchestrator: accept `mode` param, trigger `payWinner()` on ranked fight end 10. Add `recoverOrphanedPayments()` to startup 11. Test: two bots pay and fight, winner receives 42 sats ### Phase 3: Frontend Wallet 12. Implement `frontend/src/composables/useWallet.ts` 13. Implement `frontend/src/components/WalletConnect.vue` 14. Modify `JoinBoutPage.vue`: add ranked fight button + payment flow 15. Modify `FightPage.vue`: show sats on the line, winner payout celebration 16. Modify `BotProfilePage.vue`: sats stats + wallet management ### Phase 4: Cashu Alternative 17. Add Cashu token submission to `payments.ts` + route 18. Add Cashu payout fallback for offline winners 19. Add claim UI on profile page ### Phase 5: Polish 20. "RANKED" badge on fight cards in arena list 21. Sats leaderboard column 22. Error recovery hardening 23. Rate limiting on payment endpoints --- ## Security - **Server wallet secret**: env var only, never in DB or code - **User NWC strings**: encrypted at rest (AES-256-GCM) in `wallet_connections` - **No user balances**: server holds funds for seconds, not as deposits - **Invoice expiry**: 10 minutes max - **Rate limiting**: same pattern as existing `rateLimit` middleware - **Refund-on-failure**: default for every error path - **No tracking**: payment records use bot IDs only, no IPs or extra identifiers - **Cashu privacy**: ecash is unlinkable — mint can't connect sender to receiver --- ## Key Files Reference | File | Role | |------|------| | `server/src/engine/queue.ts` | Pattern to follow for ranked-queue.ts | | `server/src/engine/orchestrator.ts` | Fight lifecycle — add payment gates + payout trigger | | `server/src/db/schema.ts` | Add new tables + columns | | `server/src/db/startup.ts` | Migration SQL | | `server/src/routes/queue.ts` | Add ranked endpoint | | `server/src/app.ts` | Mount payments router + startup recovery | | `frontend/src/composables/useNostr.ts` | Pattern to follow for useWallet.ts | | `frontend/src/pages/JoinBoutPage.vue` | Add ranked fight button + wallet UI | | `frontend/src/pages/FightPage.vue` | Show pot + winner payout | | `frontend/src/pages/BotProfilePage.vue` | Sats stats + wallet management |