Files
botfights/Zaps.md
T
DorianandClaude Opus 4.6 88d0495910 docs: Zaps.md — Lightning payment integration plan
Comprehensive implementation guide for 21-sat ranked fights (winner takes all).
Covers NWC + Cashu payment flows, database schema, server/frontend changes,
edge cases, security, and phased implementation order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:38:36 +00:00

522 lines
18 KiB
Markdown

# 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<void>
// 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<void>
// 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<boolean>
// Validate ecash token with mint
// If valid: mark payment as confirmed
// If spent/invalid: return false
export async function recoverOrphanedPayments(): Promise<void>
// 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<typeof setTimeout>
}
export function joinRankedQueue(botId: string, paymentId: string): Promise<string>
// 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<void>
// POST /api/payments/connect-wallet
// Persist to localStorage (bf_wallet_method)
async function connectLightningAddress(address: string): Promise<void>
// POST /api/payments/connect-wallet
async function disconnectWallet(): Promise<void>
// DELETE /api/payments/disconnect-wallet
async function checkWalletStatus(): Promise<void>
// GET /api/payments/wallet-status
async function payEntryFee(botId: string): Promise<string>
// 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<string>
// 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
<!-- Existing free fight (unchanged) -->
<button @click="fight">FIGHT FREE</button>
<!-- New ranked fight -->
<button @click="fightRanked" :disabled="!isWalletConnected">
FIGHT FOR SATS
<span class="text-[9px]">21 SATS — WINNER TAKES ALL</span>
</button>
<!-- Wallet connection (shown if no wallet) -->
<WalletConnect v-if="!isWalletConnected" />
```
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 |