- Added 'consumed' to payment status enum (was missing from schema) - Fixed orphan recovery to also find 'consumed' payments on restart - Verified: NWC + Lightning Address wallet connect, invoice creation, 21-sat entry fee, 42-sat winner payout, Cashu tokens, bet settlement, refund on cancelled/timeout, stuck payment recovery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
680 lines
25 KiB
TypeScript
680 lines
25 KiB
TypeScript
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://<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 {
|
|
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<string> {
|
|
// 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<string> {
|
|
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<string, unknown>,
|
|
): Promise<Record<string, unknown>> {
|
|
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<Record<string, unknown>>((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<string, unknown>
|
|
}
|
|
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<void> {
|
|
// 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<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, comment?: string): 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))
|
|
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<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(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<void> {
|
|
// 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<string>()
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
// 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<void> {
|
|
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 }
|