feat: payment engine — NWC Lightning + Cashu ecash for ranked fights

Implements createEntryInvoice, checkPaymentStatus, payWinner, refundEntry,
redeemCashuToken, and recoverOrphanedPayments. Uses nostr-tools for NWC
protocol (NIP-47) and @cashu/cashu-ts for ecash fallback payouts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 01:22:27 +00:00
co-authored by Claude Opus 4.6
parent 168e1e3a32
commit abe5040742
+537
View File
@@ -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://<pubkey>?relay=<relay>&secret=<hex>
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<string, unknown>,
): 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(
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<Record<string, unknown>>((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<string, unknown>
}
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<void> {
// 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<string, unknown>,
): Promise<Record<string, unknown>> {
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<Record<string, unknown>>((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<string, unknown>
}
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<string> {
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<void> {
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<void> {
// 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 }