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:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
+118
-43
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user