import type { FastifyInstance } from "fastify"; import { validateInitData, AuthError } from "../auth"; import { findUser } from "../../db/users"; import { getSession } from "../../payments/session"; import { sendPayment, detectPaymentType } from "../../payments/wallet"; import { consumeTx } from "../../payments/session"; import { createPending, updateStatus } from "../../db/pending"; interface SendBody { destination: string; // BOLT11 invoice | Spark address | Bitcoin address amountSats?: number; // required for Spark/on-chain; embedded in BOLT11 description?: string; } export async function sendRoutes(app: FastifyInstance): Promise { app.post<{ Body: SendBody }>("/api/send", async (req, reply) => { const initData = req.headers["x-init-data"] as string | undefined; if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" }); let userId: number; try { const validated = validateInitData(initData); userId = validated.user.id; } catch (err) { return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" }); } if (!findUser(userId)) return reply.status(404).send({ error: "Wallet not found" }); const { destination, amountSats, description } = req.body ?? {}; if (!destination || typeof destination !== "string") { return reply.status(400).send({ error: "destination is required" }); } const paymentType = await detectPaymentType(destination); const MAX_SATS = 21_000_000 * 100_000_000; if (amountSats !== undefined && (amountSats <= 0 || amountSats > MAX_SATS || !Number.isInteger(amountSats))) { return reply.status(400).send({ error: "amountSats must be a positive integer ≤ 21M BTC in sats" }); } if ((paymentType === "spark" || paymentType === "onchain") && !amountSats) { return reply.status(400).send({ error: "amountSats is required for Spark and on-chain payments" }); } const session = getSession(userId); if (!session) { return reply.status(403).send({ error: "wallet_locked", message: "Unlock your wallet first.", }); } const sats = amountSats ?? 0; // Record the transaction for history const txId = createPending({ initiatorUserId: userId, recipientUserId: null, recipientAddress: destination, amountSats: sats, initiatedVia: "command", // mini app sends are treated same as commands groupChatId: null, }); updateStatus(txId, "processing"); try { const result = await sendPayment(userId, destination, sats); consumeTx(userId); updateStatus(txId, "done"); return { success: true, txId: result.txId, feeSats: result.feeSats, paymentType, }; } catch (err) { updateStatus(txId, "failed"); const message = err instanceof Error ? err.message : "Payment failed"; return reply.status(500).send({ error: message }); } }); }