import type { FastifyInstance } from "fastify"; import { validateInitData, AuthError } from "../auth"; import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../ratelimit"; import { findUser, createUser } from "../../db/users"; import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet"; import { encryptMnemonic } from "../../payments/crypto"; import { publishToRegistry } from "../../payments/registry"; const MIN_PIN_LENGTH = 6; interface SetupBody { pin: string; } export async function setupRoutes(app: FastifyInstance): Promise { /** * POST /api/setup * * Creates a new wallet for the authenticated Telegram user. * Returns the seed words once in the response — the Mini App displays and * discards them. Nothing is stored in Telegram chat history. */ app.post<{ Body: SetupBody }>("/api/setup", 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; let username: string | undefined; try { const validated = validateInitData(initData); userId = validated.user.id; username = validated.user.username; } catch (err) { return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" }); } if (findUser(userId)) { return reply.status(409).send({ error: "Wallet already exists." }); } const { pin } = req.body ?? {}; if (!pin || typeof pin !== "string") { return reply.status(400).send({ error: "pin is required" }); } if (pin.length < MIN_PIN_LENGTH) { return reply.status(400).send({ error: `PIN must be at least ${MIN_PIN_LENGTH} characters.` }); } try { checkPinRateLimit(userId); } catch (err) { if (err instanceof RateLimitError) return reply.status(429).send({ error: err.message }); throw err; } // Generate and encrypt — PIN is never stored, only the encrypted blob const mnemonic = generateMnemonic(); const sparkAddress = await deriveSparkAddress(mnemonic); const encryptedMnemonic = encryptMnemonic(mnemonic, pin); createUser(userId, username ?? null, encryptedMnemonic, sparkAddress); recordPinSuccess(userId); await publishToRegistry(userId, sparkAddress).catch(() => null); // Return seed words directly — this is an authenticated HTTPS call, // nothing is written to Telegram's servers. return { words: mnemonic.split(" "), sparkAddress, }; }); }