diff --git a/Zaps.md b/Zaps.md new file mode 100644 index 0000000..4febdaf --- /dev/null +++ b/Zaps.md @@ -0,0 +1,521 @@ +# 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 |