import { nanoid } from 'nanoid' import { db, schema, sqlite } from '../db/index.js' import { eq, and, isNull, sql } from 'drizzle-orm' 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, 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 — 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 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 { 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 { // 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 { 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 */ async function nwcRequest( method: string, params: Record, ): Promise> { const nwc = getNwcConfig() const clientSecret = nwc.secret const content = await nwcEncrypt( JSON.stringify({ method, params }), clientSecret, nwc.pubkey, ) const event = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now() / 1000), tags: [['p', nwc.pubkey]], 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>((resolve, reject) => { const timeout = setTimeout(() => { console.log(`[nwc] ${method} timed out — is your wallet online?`) relay.close() 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] }], { async onevent(responseEvent) { clearTimeout(timeout) console.log(`[nwc] got response for ${method}`) try { 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 } 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) { reject(err) } finally { sub.close() relay.close() } }, oneose() {}, }, ) // Publish the request 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() reject(err) }) }) } catch (err) { relay.close() throw err } } /** 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)`, expiry: INVOICE_EXPIRY_SECS, }) const bolt11 = result.invoice as string if (!bolt11) throw new Error('NWC make_invoice did not return an invoice') 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 { // Dev mode: use Lightning Address from env to avoid self-payment on same LND node const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : '' // Look up winner name for payout description const winnerRows = await db.select({ name: schema.bots.name }) .from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1) const winnerName = winnerRows[0]?.name || 'unknown' // Look up loser for the payout message const fightRows = await db.select({ botAId: schema.fights.botAId, botBId: schema.fights.botBId, }).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1) let loserName = 'opponent' if (fightRows.length > 0) { const loserId = fightRows[0].botAId === winnerId ? fightRows[0].botBId : fightRows[0].botAId const loserRows = await db.select({ name: schema.bots.name }) .from(schema.bots).where(eq(schema.bots.id, loserId)).limit(1) loserName = loserRows[0]?.name || 'opponent' } const payoutDesc = `BOTFIGHTS VICTORY: ${winnerName} defeated ${loserName}! ${POT_SATS} sats prize` // 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 (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, payoutDesc) 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, payoutDesc) 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(decrypt(wallet.connectionData), 'make_invoice', { amount: POT_SATS * 1000, description: payoutDesc, }) 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 (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) 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: getCashuMintUrl(), proofs, unit: 'sat' }) paymentMethod = 'cashu' } else { console.log(`[payments] no payout method available for winner ${winnerId}`) paymentMethod = 'lightning' } // 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, comment?: string): 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)) if (comment) callbackUrl.searchParams.set('comment', comment) 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(decrypt(walletRows[0].connectionData), 'make_invoice', { amount: ENTRY_FEE_SATS * 1000, description: `BOTFIGHTS REFUND: ${ENTRY_FEE_SATS} sats ranked entry fee returned`, }) const invoice = result.invoice as string if (invoice) { await nwcRequest('pay_invoice', { invoice }) } } else if (walletRows[0]?.method === 'lnaddress') { const invoice = await resolveAndCreateInvoice(decrypt(walletRows[0].connectionData), ENTRY_FEE_SATS) await nwcRequest('pay_invoice', { invoice }) } else if (getCashuMintUrl()) { // Fallback: issue Cashu token const cashuWallet = new CashuWallet(getCashuMintUrl()) 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: 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' && getCashuMintUrl()) { const cashuWallet = new CashuWallet(getCashuMintUrl()) 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: getCashuMintUrl(), 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 (!getCashuMintUrl()) 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(getCashuMintUrl()) 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/consumed entry payments without a fight — refund them // 'consumed' payments were in the queue when server restarted const orphaned = await db.select().from(schema.payments) .where(and( sql`${schema.payments.status} IN ('confirmed', 'consumed')`, 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') } } // In-memory set of payment IDs currently consumed by the ranked queue. // Safe because Node.js is single-threaded and the ranked queue is also in-memory. // On server restart, the queue is empty and recoverOrphanedPayments handles cleanup. const consumedPayments = new Set() /** * Consume a confirmed entry payment for queue use. * Returns true if the payment was successfully consumed, false if already used. */ export async function consumePaymentForQueue(paymentId: string, botId: string): Promise { // Fast path: already consumed in this server lifetime if (consumedPayments.has(paymentId)) return false // Atomic: only marks as consumed if ALL conditions met in a single UPDATE // Eliminates race window between SELECT check and later UPDATE const result = sqlite.prepare( `UPDATE payments SET status = 'consumed' WHERE id = ? AND bot_id = ? AND status = 'confirmed' AND direction = 'in' AND fight_id IS NULL` ).run(paymentId, botId) if (result.changes === 0) return false consumedPayments.add(paymentId) return true } /** * Link consumed entry payments to the actual fight. * Called after a ranked fight is created. Also removes from consumed set. */ export async function linkPaymentsToFight(fightId: string, paymentIds: string[]): Promise { for (const pid of paymentIds) { await db.update(schema.payments).set({ fightId }) .where(eq(schema.payments.id, pid)) consumedPayments.delete(pid) } } /** * Release a consumed payment back for refund when queue times out or bot leaves. */ export function releasePayment(paymentId: string): void { consumedPayments.delete(paymentId) // Revert DB status so the payment can be re-consumed or refunded sqlite.prepare(`UPDATE payments SET status = 'confirmed' WHERE id = ? AND status = 'consumed'`).run(paymentId) } export { ENTRY_FEE_SATS, POT_SATS }