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