diff --git a/server/src/engine/payments.ts b/server/src/engine/payments.ts new file mode 100644 index 0000000..a070344 --- /dev/null +++ b/server/src/engine/payments.ts @@ -0,0 +1,537 @@ +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 }