feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots

- Fight Card page: dramatic poster background with cross-hatch, spotlights,
  vignettes, corner brackets, scan lines; 3D VS orb with punch animation;
  selectable undercard with main event always pinned at top
- PosterSprite: high-quality 480px poster frame with 6-pass renderer
  (aura, glow, bevel, specular, particles); PixelGlove component
- 12-char bot name limit across all forms and server validation
- Mock bots: all 100 now have diverse archetypes (25 types), 25% human
  fighters; seedMockBots updates existing bots on restart
- Leaderboard: inline SpritePreview next to each bot name
- Nostr auth: persistent login, nsec copy button
- Wallet: NWC + Lightning Address, ranked fight flow
- Server: payments, ranked queue, customization endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 10:33:30 +00:00
co-authored by Claude Opus 4.6
parent ccf4196647
commit f6eb7d2845
32 changed files with 1743 additions and 467 deletions
+118 -43
View File
@@ -1,20 +1,37 @@
import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { eq, and, isNull, sql } from 'drizzle-orm'
import { finalizeEvent } from 'nostr-tools'
import { finalizeEvent, getPublicKey } from 'nostr-tools'
import * as nip04 from 'nostr-tools/nip04'
import * as nip44 from 'nostr-tools/nip44'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from 'nostr-tools/utils'
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
import { Wallet as CashuWallet, getEncodedToken, getDecodedToken } from '@cashu/cashu-ts'
import { decrypt } from './crypto.js'
const ENTRY_FEE_SATS = 21
const POT_SATS = 42
const INVOICE_EXPIRY_SECS = 600 // 10 minutes
const NWC_RESPONSE_TIMEOUT_MS = 30_000
// NWC config from env
const NWC_URL = process.env.BOTFIGHTS_NWC_URL || ''
const CASHU_MINT_URL = process.env.BOTFIGHTS_CASHU_MINT_URL || ''
// NWC config from env — load dotenv inline as fallback
import { config as dotenvConfig } from 'dotenv'
import { dirname as _dirname, join as _join } from 'path'
import { fileURLToPath as _fileURLToPath } from 'url'
let _envLoaded = false
function ensureEnv() {
if (_envLoaded) return
_envLoaded = true
if (!process.env.BOTFIGHTS_NWC_URL) {
const envPath = _join(_dirname(_fileURLToPath(import.meta.url)), '..', '..', '.env')
dotenvConfig({ path: envPath })
}
}
function getNwcUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_NWC_URL || '' }
function getCashuMintUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_CASHU_MINT_URL || '' }
function getDevPayoutAddress(): string { ensureEnv(); return process.env.BOTFIGHTS_DEV_PAYOUT_LNADDRESS || '' }
interface NwcConfig {
pubkey: string
@@ -37,8 +54,28 @@ export function parseNwcUrl(url: string): NwcConfig {
}
function getNwcConfig(): NwcConfig {
if (!NWC_URL) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
return parseNwcUrl(NWC_URL)
const url = getNwcUrl()
if (!url) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
return parseNwcUrl(url)
}
/** Encrypt content — try NIP-04 first (BTCPay/LND), with NIP-44 fallback */
async function nwcEncrypt(plaintext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
// NIP-04 is what most NWC wallets (BTCPay, LND, Alby Hub) expect
const secretHex = bytesToHex(secret)
return nip04.encrypt(secretHex, walletPubkey, plaintext)
}
/** Decrypt response — try NIP-04 first, fall back to NIP-44 */
async function nwcDecrypt(ciphertext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
const secretHex = bytesToHex(secret)
try {
return await nip04.decrypt(secretHex, walletPubkey, ciphertext)
} catch {
// Fall back to NIP-44
const conversationKey = nip44.v2.utils.getConversationKey(secret, walletPubkey)
return nip44.v2.decrypt(ciphertext, conversationKey)
}
}
/** Send an NWC request and wait for the response */
@@ -48,11 +85,11 @@ async function nwcRequest(
): Promise<Record<string, unknown>> {
const nwc = getNwcConfig()
const clientSecret = nwc.secret
const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey)
const content = nip44.v2.encrypt(
const content = await nwcEncrypt(
JSON.stringify({ method, params }),
conversationKey,
clientSecret,
nwc.pubkey,
)
const event = finalizeEvent({
@@ -62,31 +99,37 @@ async function nwcRequest(
content,
}, clientSecret)
console.log(`[nwc] connecting to relay ${nwc.relay}...`)
const relay = await Relay.connect(nwc.relay)
console.log(`[nwc] relay connected, sending ${method} request (event ${event.id.slice(0, 8)}...)`)
try {
return await new Promise<Record<string, unknown>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.log(`[nwc] ${method} timed out — is your wallet online?`)
relay.close()
reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`))
reject(new Error(`NWC ${method} timed out after ${NWC_RESPONSE_TIMEOUT_MS / 1000}s. Is your wallet online?`))
}, NWC_RESPONSE_TIMEOUT_MS)
// Subscribe for the response (kind 23195)
const sub = relay.subscribe(
[{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }],
{
onevent(responseEvent) {
async onevent(responseEvent) {
clearTimeout(timeout)
console.log(`[nwc] got response for ${method}`)
try {
const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey)
const decrypted = await nwcDecrypt(responseEvent.content, clientSecret, nwc.pubkey)
const result = JSON.parse(decrypted) as {
result_type: string
error?: { code: string; message: string }
result?: Record<string, unknown>
}
if (result.error) {
console.log(`[nwc] ${method} error: ${result.error.message}`)
reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`))
} else {
console.log(`[nwc] ${method} success`)
resolve(result.result || {})
}
} catch (err) {
@@ -96,14 +139,15 @@ async function nwcRequest(
relay.close()
}
},
oneose() {
// End of stored events — just wait for live events
},
oneose() {},
},
)
// Publish the request
relay.publish(event).catch((err) => {
relay.publish(event).then(() => {
console.log(`[nwc] ${method} event published, waiting for wallet response...`)
}).catch((err) => {
console.log(`[nwc] publish failed:`, err)
clearTimeout(timeout)
sub.close()
relay.close()
@@ -117,11 +161,32 @@ async function nwcRequest(
}
/** Create a 21-sat Lightning invoice for a ranked fight entry fee */
const DEV_AUTO_CONFIRM = process.env.NODE_ENV !== 'production'
export async function createEntryInvoice(botId: string): Promise<{ bolt11: string; paymentId: string }> {
// Verify bot exists
const botRows = await db.select({ id: schema.bots.id }).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
const paymentId = nanoid(12)
if (DEV_AUTO_CONFIRM) {
// Dev mode: skip real invoice, auto-confirm so flow works without separate wallets
await db.insert(schema.payments).values({
id: paymentId,
botId,
direction: 'in',
amountSats: ENTRY_FEE_SATS,
method: 'lightning',
status: 'confirmed',
invoice: 'dev_auto_confirmed',
confirmedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
})
console.log(`[payments] dev: auto-confirmed 21 sat entry for ${botId} (self-payment skipped — payouts are real)`)
return { bolt11: 'dev_auto_confirmed', paymentId }
}
const result = await nwcRequest('make_invoice', {
amount: ENTRY_FEE_SATS * 1000, // NWC uses millisats
description: `Botfights ranked entry fee (${ENTRY_FEE_SATS} sats)`,
@@ -131,7 +196,6 @@ export async function createEntryInvoice(botId: string): Promise<{ bolt11: strin
const bolt11 = result.invoice as string
if (!bolt11) throw new Error('NWC make_invoice did not return an invoice')
const paymentId = nanoid(12)
await db.insert(schema.payments).values({
id: paymentId,
botId,
@@ -179,6 +243,9 @@ export async function checkPaymentStatus(paymentId: string): Promise<'pending' |
/** Pay the winner of a ranked fight */
export async function payWinner(fightId: string, winnerId: string): Promise<void> {
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
// Look up winner's wallet connection
const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -192,9 +259,22 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
for (let attempt = 0; attempt <= retries.length; attempt++) {
try {
if (wallet?.method === 'nwc') {
if (devPayoutAddr) {
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'nwc') {
// Request invoice from winner's NWC wallet, then pay it via server wallet
const winnerResult = await nwcRequestVia(wallet.connectionData, 'make_invoice', {
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
amount: POT_SATS * 1000,
description: `Botfights ranked win payout (${POT_SATS} sats)`,
})
@@ -205,18 +285,9 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(wallet.connectionData, POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else {
// No wallet — create Cashu token for later claim
if (!CASHU_MINT_URL) {
throw new Error('Cannot create Cashu payout: no mint URL configured')
}
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
} else if (getCashuMintUrl()) {
// No wallet + mint configured — create Cashu token for later claim
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(POT_SATS)
@@ -225,8 +296,12 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(POT_SATS, mintQuote.quote)
cashuToken = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
cashuToken = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
paymentMethod = 'cashu'
} else {
console.log(`[payments] no payout method available for winner ${winnerId}`)
paymentMethod = 'lightning'
}
// Insert payout record
@@ -397,7 +472,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
.where(eq(schema.walletConnections.botId, payment.botId)).limit(1)
if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(walletRows[0].connectionData, 'make_invoice', {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000,
description: 'Botfights ranked refund',
})
@@ -406,11 +481,11 @@ export async function refundEntry(paymentId: string): Promise<void> {
await nwcRequest('pay_invoice', { invoice })
}
} else if (walletRows[0]?.method === 'lnaddress') {
const invoice = await resolveAndCreateInvoice(walletRows[0].connectionData, ENTRY_FEE_SATS)
const invoice = await resolveAndCreateInvoice(decrypt(walletRows[0].connectionData), ENTRY_FEE_SATS)
await nwcRequest('pay_invoice', { invoice })
} else if (CASHU_MINT_URL) {
} else if (getCashuMintUrl()) {
// Fallback: issue Cashu token
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
if (mintQuote.request) {
@@ -418,13 +493,13 @@ export async function refundEntry(paymentId: string): Promise<void> {
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
}
}
// For cashu entries, the token is already spent — mint new one as refund
if (payment.method === 'cashu' && CASHU_MINT_URL) {
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
if (payment.method === 'cashu' && getCashuMintUrl()) {
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
if (mintQuote.request) {
@@ -432,7 +507,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
}
@@ -453,7 +528,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
/** Redeem a Cashu token as entry fee */
export async function redeemCashuToken(token: string, botId: string): Promise<{ paymentId: string; valid: boolean }> {
if (!CASHU_MINT_URL) throw new Error('Cashu mint not configured')
if (!getCashuMintUrl()) throw new Error('Cashu mint not configured')
try {
const decoded = getDecodedToken(token)
@@ -462,7 +537,7 @@ export async function redeemCashuToken(token: string, botId: string): Promise<{
return { paymentId: '', valid: false }
}
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
// Receive the token (swap for fresh proofs — prevents double-spend)