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>
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):
- Parse
nostr+walletconnect://URI → extract pubkey, relay URL, secret - Open WebSocket to relay
- Create kind 23194 event (pay_invoice request) encrypted with NIP-44
- Wait for kind 23195 response event
- 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:
- No wallet: "CONNECT WALLET" button → opens modal/inline form with two options:
- Paste NWC connection string
- Enter Lightning Address (for payouts only)
- Connected: Small lightning bolt icon + "WALLET READY" + disconnect link
- Paying: "PAYING 21 SATS..." spinner (NWC auto-pay) OR QR code (manual pay)
- 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:
payEntryFee(botId)→ creates invoice, pays via NWC or shows QR- Poll until confirmed
POST /api/queue/join-ranked/:botIdwith{ paymentId }- Navigate to
/arena/:fightIdwhen 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_endSSE event carriesmodeandpotSats
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)
- Add new columns to
fightsandbotsinserver/src/db/schema.ts - Create
paymentsandwallet_connectionstables - Run migrations in
server/src/db/startup.ts - Implement
server/src/engine/payments.ts(NWC protocol, invoice creation, status checking, payouts) - Implement
server/src/routes/payments.ts(all endpoints) - Test with curl: create invoice, simulate payment, check status
Phase 2: Ranked Queue (server only)
- Implement
server/src/engine/ranked-queue.ts - Add
POST /api/queue/join-ranked/:botIdto queue routes - Modify orchestrator: accept
modeparam, triggerpayWinner()on ranked fight end - Add
recoverOrphanedPayments()to startup - Test: two bots pay and fight, winner receives 42 sats
Phase 3: Frontend Wallet
- Implement
frontend/src/composables/useWallet.ts - Implement
frontend/src/components/WalletConnect.vue - Modify
JoinBoutPage.vue: add ranked fight button + payment flow - Modify
FightPage.vue: show sats on the line, winner payout celebration - Modify
BotProfilePage.vue: sats stats + wallet management
Phase 4: Cashu Alternative
- Add Cashu token submission to
payments.ts+ route - Add Cashu payout fallback for offline winners
- Add claim UI on profile page
Phase 5: Polish
- "RANKED" badge on fight cards in arena list
- Sats leaderboard column
- Error recovery hardening
- 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
rateLimitmiddleware - 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 |