2026-03-08 01:26:11 +00:00
|
|
|
import { Hono } from 'hono'
|
|
|
|
|
import { nanoid } from 'nanoid'
|
|
|
|
|
import { db, schema } from '../db/index.js'
|
|
|
|
|
import { eq } from 'drizzle-orm'
|
|
|
|
|
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
2026-03-08 10:33:30 +00:00
|
|
|
import { encrypt, decrypt } from '../engine/crypto.js'
|
2026-03-08 01:26:11 +00:00
|
|
|
|
|
|
|
|
export const paymentsRouter = new Hono()
|
|
|
|
|
|
|
|
|
|
// POST /connect-wallet
|
|
|
|
|
paymentsRouter.post('/connect-wallet', async (c) => {
|
|
|
|
|
const { pubkey, method, connectionData } = await c.req.json<{
|
|
|
|
|
pubkey: string
|
|
|
|
|
method: 'nwc' | 'lnaddress' | 'cashu_mint'
|
|
|
|
|
connectionData: string
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
if (!pubkey || !method || !connectionData) {
|
|
|
|
|
return c.json({ error: 'Missing pubkey, method, or connectionData' }, 400)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Look up bot by publicKey
|
|
|
|
|
const botRows = await db.select({ id: schema.bots.id })
|
|
|
|
|
.from(schema.bots)
|
|
|
|
|
.where(eq(schema.bots.publicKey, pubkey))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (botRows.length === 0) {
|
|
|
|
|
return c.json({ error: 'Bot not found for this public key' }, 404)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const botId = botRows[0].id
|
|
|
|
|
const encryptedData = encrypt(connectionData)
|
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
|
|
|
|
|
|
// Upsert wallet connection
|
|
|
|
|
const existing = await db.select({ id: schema.walletConnections.id })
|
|
|
|
|
.from(schema.walletConnections)
|
|
|
|
|
.where(eq(schema.walletConnections.botId, botId))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
await db.update(schema.walletConnections).set({
|
|
|
|
|
method,
|
|
|
|
|
connectionData: encryptedData,
|
|
|
|
|
lastUsedAt: now,
|
|
|
|
|
}).where(eq(schema.walletConnections.botId, botId))
|
|
|
|
|
} else {
|
|
|
|
|
await db.insert(schema.walletConnections).values({
|
|
|
|
|
id: nanoid(12),
|
|
|
|
|
botId,
|
|
|
|
|
method,
|
|
|
|
|
connectionData: encryptedData,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.update(schema.bots).set({ hasWallet: true }).where(eq(schema.bots.id, botId))
|
|
|
|
|
|
|
|
|
|
return c.json({ success: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// GET /wallet-status
|
|
|
|
|
paymentsRouter.get('/wallet-status', async (c) => {
|
|
|
|
|
const pubkey = c.req.query('pubkey')
|
|
|
|
|
if (!pubkey) return c.json({ connected: false, method: null })
|
|
|
|
|
|
|
|
|
|
const botRows = await db.select({ id: schema.bots.id })
|
|
|
|
|
.from(schema.bots)
|
|
|
|
|
.where(eq(schema.bots.publicKey, pubkey))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (botRows.length === 0) return c.json({ connected: false, method: null })
|
|
|
|
|
|
|
|
|
|
const walletRows = await db.select({ method: schema.walletConnections.method })
|
|
|
|
|
.from(schema.walletConnections)
|
|
|
|
|
.where(eq(schema.walletConnections.botId, botRows[0].id))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (walletRows.length === 0) return c.json({ connected: false, method: null })
|
|
|
|
|
|
|
|
|
|
return c.json({ connected: true, method: walletRows[0].method })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// POST /create-invoice
|
|
|
|
|
paymentsRouter.post('/create-invoice', async (c) => {
|
|
|
|
|
const { botId } = await c.req.json<{ botId: string }>()
|
|
|
|
|
if (!botId) return c.json({ error: 'Missing botId' }, 400)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await createEntryInvoice(botId)
|
|
|
|
|
return c.json(result)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Invoice creation failed'
|
|
|
|
|
return c.json({ error: message }, 500)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// GET /check/:paymentId
|
|
|
|
|
paymentsRouter.get('/check/:paymentId', async (c) => {
|
|
|
|
|
const paymentId = c.req.param('paymentId')
|
|
|
|
|
try {
|
|
|
|
|
const status = await checkPaymentStatus(paymentId)
|
|
|
|
|
return c.json({ status })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Status check failed'
|
|
|
|
|
return c.json({ error: message }, 500)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-08 10:33:30 +00:00
|
|
|
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
|
|
|
|
|
paymentsRouter.post('/confirm/:paymentId', async (c) => {
|
|
|
|
|
const paymentId = c.req.param('paymentId')
|
|
|
|
|
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
|
|
|
|
|
|
|
|
|
|
const rows = await db.select().from(schema.payments)
|
|
|
|
|
.where(eq(schema.payments.id, paymentId)).limit(1)
|
|
|
|
|
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
|
|
|
|
|
|
|
|
|
|
const payment = rows[0]
|
|
|
|
|
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
|
|
|
|
|
|
|
|
|
|
// Verify caller owns this payment's bot
|
|
|
|
|
if (pubkey) {
|
|
|
|
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
|
|
|
|
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
|
|
|
|
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
|
|
|
|
return c.json({ error: 'Unauthorized' }, 403)
|
|
|
|
|
}
|
|
|
|
|
} else if (process.env.NODE_ENV === 'production') {
|
|
|
|
|
return c.json({ error: 'Missing pubkey' }, 400)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.update(schema.payments).set({
|
|
|
|
|
status: 'confirmed',
|
|
|
|
|
preimage: preimage || null,
|
|
|
|
|
confirmedAt: new Date().toISOString(),
|
|
|
|
|
}).where(eq(schema.payments.id, paymentId))
|
|
|
|
|
|
|
|
|
|
console.log(`[payments] payment ${paymentId} confirmed via client (preimage: ${preimage ? 'yes' : 'no'})`)
|
|
|
|
|
return c.json({ status: 'confirmed' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-08 01:26:11 +00:00
|
|
|
// POST /submit-cashu
|
|
|
|
|
paymentsRouter.post('/submit-cashu', async (c) => {
|
|
|
|
|
const { botId, token } = await c.req.json<{ botId: string; token: string }>()
|
|
|
|
|
if (!botId || !token) return c.json({ error: 'Missing botId or token' }, 400)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await redeemCashuToken(token, botId)
|
|
|
|
|
return c.json({
|
|
|
|
|
paymentId: result.paymentId,
|
|
|
|
|
status: result.valid ? 'confirmed' : 'failed',
|
|
|
|
|
})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Cashu redemption failed'
|
|
|
|
|
return c.json({ error: message }, 500)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// GET /winnings/:botId
|
|
|
|
|
paymentsRouter.get('/winnings/:botId', async (c) => {
|
|
|
|
|
const botId = c.req.param('botId')
|
|
|
|
|
|
|
|
|
|
const unclaimed = await db.select({
|
|
|
|
|
paymentId: schema.payments.id,
|
|
|
|
|
cashuToken: schema.payments.cashuToken,
|
|
|
|
|
amountSats: schema.payments.amountSats,
|
|
|
|
|
}).from(schema.payments)
|
|
|
|
|
.where(eq(schema.payments.botId, botId))
|
|
|
|
|
|
|
|
|
|
// Filter in JS since drizzle doesn't easily combine multiple conditions
|
|
|
|
|
const filtered = unclaimed.filter(p => p.cashuToken)
|
|
|
|
|
|
|
|
|
|
return c.json({ unclaimed: filtered })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// POST /claim/:paymentId
|
|
|
|
|
paymentsRouter.post('/claim/:paymentId', async (c) => {
|
|
|
|
|
const paymentId = c.req.param('paymentId')
|
2026-03-08 10:33:30 +00:00
|
|
|
const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined }))
|
2026-03-08 01:26:11 +00:00
|
|
|
|
|
|
|
|
const rows = await db.select().from(schema.payments)
|
|
|
|
|
.where(eq(schema.payments.id, paymentId))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
|
|
|
|
|
|
|
|
|
|
const payment = rows[0]
|
2026-03-08 10:33:30 +00:00
|
|
|
|
|
|
|
|
// Verify caller owns this payment's bot
|
|
|
|
|
if (pubkey) {
|
|
|
|
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
|
|
|
|
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
|
|
|
|
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
|
|
|
|
return c.json({ error: 'Unauthorized' }, 403)
|
|
|
|
|
}
|
|
|
|
|
} else if (process.env.NODE_ENV === 'production') {
|
|
|
|
|
return c.json({ error: 'Missing pubkey' }, 400)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 01:26:11 +00:00
|
|
|
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
|
|
|
|
|
|
|
|
|
|
// Clear the token from DB after claiming
|
|
|
|
|
await db.update(schema.payments).set({ cashuToken: null })
|
|
|
|
|
.where(eq(schema.payments.id, paymentId))
|
|
|
|
|
|
|
|
|
|
return c.json({ cashuToken: payment.cashuToken })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// DELETE /disconnect-wallet
|
|
|
|
|
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
|
|
|
|
const { pubkey } = await c.req.json<{ pubkey: string }>()
|
|
|
|
|
if (!pubkey) return c.json({ error: 'Missing pubkey' }, 400)
|
|
|
|
|
|
|
|
|
|
const botRows = await db.select({ id: schema.bots.id })
|
|
|
|
|
.from(schema.bots)
|
|
|
|
|
.where(eq(schema.bots.publicKey, pubkey))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (botRows.length === 0) return c.json({ error: 'Bot not found' }, 404)
|
|
|
|
|
|
|
|
|
|
const botId = botRows[0].id
|
|
|
|
|
|
|
|
|
|
await db.delete(schema.walletConnections)
|
|
|
|
|
.where(eq(schema.walletConnections.botId, botId))
|
|
|
|
|
|
|
|
|
|
await db.update(schema.bots).set({ hasWallet: false })
|
|
|
|
|
.where(eq(schema.bots.id, botId))
|
|
|
|
|
|
|
|
|
|
return c.json({ success: true })
|
|
|
|
|
})
|