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 * as nip44 from 'nostr-tools/nip44' import { Relay } from 'nostr-tools/relay' import { hexToBytes } from 'nostr-tools/utils' import { Wallet as CashuWallet, getEncodedToken, getDecodedToken } from '@cashu/cashu-ts' 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 || '' interface NwcConfig { pubkey: string relay: string secret: Uint8Array } /** Parse nostr+walletconnect:// URI into components */ export function parseNwcUrl(url: string): NwcConfig { // Format: nostr+walletconnect://?relay=&secret= const withoutScheme = url.replace('nostr+walletconnect://', '') const [pubkey, queryString] = withoutScheme.split('?') const params = new URLSearchParams(queryString) const relay = params.get('relay') const secret = params.get('secret') if (!pubkey || !relay || !secret) { throw new Error('Invalid NWC URL: missing pubkey, relay, or secret') } return { pubkey, relay, secret: hexToBytes(secret) } } function getNwcConfig(): NwcConfig { if (!NWC_URL) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set') return parseNwcUrl(NWC_URL) } /** Send an NWC request and wait for the response */ async function nwcRequest( method: string, params: Record, ): Promise> { const nwc = getNwcConfig() const clientSecret = nwc.secret const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey) const content = nip44.v2.encrypt( JSON.stringify({ method, params }), conversationKey, ) const event = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now() / 1000), tags: [['p', nwc.pubkey]], content, }, clientSecret) const relay = await Relay.connect(nwc.relay) try { return await new Promise>((resolve, reject) => { const timeout = setTimeout(() => { relay.close() reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`)) }, NWC_RESPONSE_TIMEOUT_MS) // Subscribe for the response (kind 23195) const sub = relay.subscribe( [{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }], { onevent(responseEvent) { clearTimeout(timeout) try { const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey) const result = JSON.parse(decrypted) as { result_type: string error?: { code: string; message: string } result?: Record } if (result.error) { reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`)) } else { resolve(result.result || {}) } } catch (err) { reject(err) } finally { sub.close() relay.close() } }, oneose() { // End of stored events — just wait for live events }, }, ) // Publish the request relay.publish(event).catch((err) => { clearTimeout(timeout) sub.close() relay.close() reject(err) }) }) } catch (err) { relay.close() throw err } } /** Create a 21-sat Lightning invoice for a ranked fight entry fee */ 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 result = await nwcRequest('make_invoice', { amount: ENTRY_FEE_SATS * 1000, // NWC uses millisats description: `Botfights ranked entry fee (${ENTRY_FEE_SATS} sats)`, expiry: INVOICE_EXPIRY_SECS, }) 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, direction: 'in', amountSats: ENTRY_FEE_SATS, method: 'lightning', status: 'pending', invoice: bolt11, createdAt: new Date().toISOString(), }) return { bolt11, paymentId } } /** Check if a pending invoice has been paid */ export async function checkPaymentStatus(paymentId: string): Promise<'pending' | 'confirmed' | 'failed'> { const rows = await db.select().from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1) if (rows.length === 0) throw new Error('Payment not found') const payment = rows[0] if (payment.status !== 'pending') return payment.status as 'confirmed' | 'failed' try { const result = await nwcRequest('lookup_invoice', { invoice: payment.invoice, }) const settled = result.settled_at || result.paid if (settled) { const preimage = (result.preimage as string) || undefined await db.update(schema.payments).set({ status: 'confirmed', preimage, confirmedAt: new Date().toISOString(), }).where(eq(schema.payments.id, paymentId)) return 'confirmed' } return 'pending' } catch (err) { console.error(`[payments] check status failed for ${paymentId}:`, err) return 'pending' } } /** Pay the winner of a ranked fight */ export async function payWinner(fightId: string, winnerId: string): Promise { // Look up winner's wallet connection const walletRows = await db.select().from(schema.walletConnections) .where(eq(schema.walletConnections.botId, winnerId)).limit(1) const wallet = walletRows[0] let paymentMethod: 'lightning' | 'cashu' = 'lightning' let invoice: string | undefined let cashuToken: string | undefined const retries = [2000, 4000, 8000] for (let attempt = 0; attempt <= retries.length; attempt++) { try { 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', { amount: POT_SATS * 1000, description: `Botfights ranked win payout (${POT_SATS} sats)`, }) invoice = winnerResult.invoice as string if (!invoice) throw new Error('Winner NWC make_invoice returned no invoice') // Pay the invoice via server wallet 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) await cashuWallet.loadMint() const mintQuote = await cashuWallet.createMintQuote(POT_SATS) if (mintQuote.request) { await nwcRequest('pay_invoice', { invoice: mintQuote.request }) } 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' }) paymentMethod = 'cashu' } // Insert payout record const paymentId = nanoid(12) await db.insert(schema.payments).values({ id: paymentId, fightId, botId: winnerId, direction: 'out', amountSats: POT_SATS, method: paymentMethod, status: 'confirmed', invoice, cashuToken, confirmedAt: new Date().toISOString(), createdAt: new Date().toISOString(), }) // Update fight payout status await db.update(schema.fights).set({ payoutStatus: 'paid' }) .where(eq(schema.fights.id, fightId)) // Update winner stats await db.update(schema.bots).set({ satsWon: sql`${schema.bots.satsWon} + ${POT_SATS}`, }).where(eq(schema.bots.id, winnerId)) console.log(`[payments] paid ${POT_SATS} sats to winner ${winnerId} for fight ${fightId}`) return } catch (err) { console.error(`[payments] payout attempt ${attempt + 1} failed for fight ${fightId}:`, err) if (attempt < retries.length) { await new Promise(r => setTimeout(r, retries[attempt])) } else { // All retries exhausted await db.update(schema.fights).set({ payoutStatus: 'failed' }) .where(eq(schema.fights.id, fightId)) throw err } } } } /** Send an NWC request via a specific NWC connection string (for winner's wallet) */ async function nwcRequestVia( nwcUrl: string, method: string, params: Record, ): Promise> { const nwc = parseNwcUrl(nwcUrl) const clientSecret = nwc.secret const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey) const content = nip44.v2.encrypt( JSON.stringify({ method, params }), conversationKey, ) const event = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now() / 1000), tags: [['p', nwc.pubkey]], content, }, clientSecret) const relay = await Relay.connect(nwc.relay) try { return await new Promise>((resolve, reject) => { const timeout = setTimeout(() => { relay.close() reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`)) }, NWC_RESPONSE_TIMEOUT_MS) const sub = relay.subscribe( [{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }], { onevent(responseEvent) { clearTimeout(timeout) try { const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey) const result = JSON.parse(decrypted) as { result_type: string error?: { code: string; message: string } result?: Record } if (result.error) { reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`)) } else { resolve(result.result || {}) } } catch (err) { reject(err) } finally { sub.close() relay.close() } }, oneose() {}, }, ) relay.publish(event).catch((err) => { clearTimeout(timeout) sub.close() relay.close() reject(err) }) }) } catch (err) { relay.close() throw err } } /** Resolve a Lightning Address to a BOLT11 invoice */ async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): Promise { const [name, domain] = lnAddress.split('@') if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`) const lnurlUrl = `https://${domain}/.well-known/lnurlp/${name}` const res = await fetch(lnurlUrl) if (!res.ok) throw new Error(`LNURL resolve failed: ${res.status}`) const data = await res.json() as { callback: string minSendable: number maxSendable: number tag: string } if (data.tag !== 'payRequest') throw new Error('Invalid LNURL response: not a pay request') const amountMillisats = amountSats * 1000 if (amountMillisats < data.minSendable || amountMillisats > data.maxSendable) { throw new Error(`Amount ${amountSats} sats out of LNURL range`) } const callbackUrl = new URL(data.callback) callbackUrl.searchParams.set('amount', String(amountMillisats)) const invoiceRes = await fetch(callbackUrl.toString()) if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`) const invoiceData = await invoiceRes.json() as { pr: string } if (!invoiceData.pr) throw new Error('LNURL callback returned no invoice') return invoiceData.pr } /** Refund an entry fee */ export async function refundEntry(paymentId: string): Promise { const rows = await db.select().from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1) if (rows.length === 0) throw new Error('Payment not found') const payment = rows[0] if (payment.status === 'refunded') return // already refunded if (payment.status !== 'confirmed') { // Mark as failed if not yet confirmed — nothing to refund await db.update(schema.payments).set({ status: 'failed' }).where(eq(schema.payments.id, paymentId)) return } try { if (payment.method === 'lightning') { // Create a refund invoice by paying back. We need the bot's wallet info. const walletRows = await db.select().from(schema.walletConnections) .where(eq(schema.walletConnections.botId, payment.botId)).limit(1) if (walletRows[0]?.method === 'nwc') { const result = await nwcRequestVia(walletRows[0].connectionData, 'make_invoice', { amount: ENTRY_FEE_SATS * 1000, description: 'Botfights ranked refund', }) const invoice = result.invoice as string if (invoice) { await nwcRequest('pay_invoice', { invoice }) } } else if (walletRows[0]?.method === 'lnaddress') { const invoice = await resolveAndCreateInvoice(walletRows[0].connectionData, ENTRY_FEE_SATS) await nwcRequest('pay_invoice', { invoice }) } else if (CASHU_MINT_URL) { // Fallback: issue Cashu token const cashuWallet = new CashuWallet(CASHU_MINT_URL) await cashuWallet.loadMint() const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS) if (mintQuote.request) { await nwcRequest('pay_invoice', { invoice: mintQuote.request }) } 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' }) 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) await cashuWallet.loadMint() const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS) if (mintQuote.request) { await nwcRequest('pay_invoice', { invoice: mintQuote.request }) } 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' }) await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId)) } await db.update(schema.payments).set({ status: 'refunded', refundedAt: new Date().toISOString(), }).where(eq(schema.payments.id, paymentId)) console.log(`[payments] refunded payment ${paymentId}`) } catch (err) { console.error(`[payments] refund failed for ${paymentId}:`, err) await db.update(schema.payments).set({ status: 'failed', errorReason: err instanceof Error ? err.message : 'Refund failed', }).where(eq(schema.payments.id, paymentId)) } } /** 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') try { const decoded = getDecodedToken(token) const totalAmount = decoded.proofs.reduce((sum, p) => sum + p.amount, 0) if (totalAmount < ENTRY_FEE_SATS) { return { paymentId: '', valid: false } } const cashuWallet = new CashuWallet(CASHU_MINT_URL) await cashuWallet.loadMint() // Receive the token (swap for fresh proofs — prevents double-spend) const proofs = await cashuWallet.receive(token) if (!proofs || proofs.length === 0) { return { paymentId: '', valid: false } } const paymentId = nanoid(12) await db.insert(schema.payments).values({ id: paymentId, botId, direction: 'in', amountSats: ENTRY_FEE_SATS, method: 'cashu', status: 'confirmed', cashuToken: token, confirmedAt: new Date().toISOString(), createdAt: new Date().toISOString(), }) return { paymentId, valid: true } } catch (err) { console.error(`[payments] Cashu redeem failed:`, err) return { paymentId: '', valid: false } } } /** Recover orphaned payments on startup */ export async function recoverOrphanedPayments(): Promise { // Find confirmed entry payments without a fight — refund them const orphaned = await db.select().from(schema.payments) .where(and( eq(schema.payments.status, 'confirmed'), eq(schema.payments.direction, 'in'), isNull(schema.payments.fightId), )) if (orphaned.length > 0) { console.log(`[payments] found ${orphaned.length} orphaned payments, refunding...`) for (const payment of orphaned) { try { await refundEntry(payment.id) } catch (err) { console.error(`[payments] orphan refund failed for ${payment.id}:`, err) } } } // Find fights with payout_status='pending' — retry payout const pendingPayouts = await db.select().from(schema.fights) .where(eq(schema.fights.payoutStatus, 'pending')) if (pendingPayouts.length > 0) { console.log(`[payments] found ${pendingPayouts.length} pending payouts, retrying...`) for (const fight of pendingPayouts) { if (fight.winnerId) { try { await payWinner(fight.id, fight.winnerId) } catch (err) { console.error(`[payments] payout retry failed for fight ${fight.id}:`, err) } } } } if (orphaned.length === 0 && pendingPayouts.length === 0) { console.log('[payments] no orphaned payments or pending payouts') } } export { ENTRY_FEE_SATS, POT_SATS }