Initial commit — federated self-custodial Spark/Lightning tip bot
- grammY bot: /start, /unlock, /tip, /contact, /claim, /settings, /wallet - AES-256-GCM mnemonic encryption with scrypt key derivation - In-memory unlock sessions with background sweep - Atomic claim handling (TOCTOU-safe) - PIN rate limiting (5 attempts → 15 min lockout) - Fastify API server + Telegram Mini App (setup, unlock, send, receive, history) - One-time seed reveal via Mini App or auto-deleted DM message - Federated registry client - Docker Compose deployment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user