Files
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

18 KiB

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:

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

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

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

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

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.

// 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.

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.

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):

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:

// 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:

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:

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.

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:

<!-- 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

// 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

# 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)

  1. Implement server/src/engine/ranked-queue.ts
  2. Add POST /api/queue/join-ranked/:botId to queue routes
  3. Modify orchestrator: accept mode param, trigger payWinner() on ranked fight end
  4. Add recoverOrphanedPayments() to startup
  5. Test: two bots pay and fight, winner receives 42 sats

Phase 3: Frontend Wallet

  1. Implement frontend/src/composables/useWallet.ts
  2. Implement frontend/src/components/WalletConnect.vue
  3. Modify JoinBoutPage.vue: add ranked fight button + payment flow
  4. Modify FightPage.vue: show sats on the line, winner payout celebration
  5. Modify BotProfilePage.vue: sats stats + wallet management

Phase 4: Cashu Alternative

  1. Add Cashu token submission to payments.ts + route
  2. Add Cashu payout fallback for offline winners
  3. Add claim UI on profile page

Phase 5: Polish

  1. "RANKED" badge on fight cards in arena list
  2. Sats leaderboard column
  3. Error recovery hardening
  4. 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