522 lines
18 KiB
Markdown
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 |
|