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:
co-authored by
Claude Opus 4.6
parent
168e1e3a32
commit
abe5040742
@@ -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 }
|
||||||
Reference in New Issue
Block a user