import { Composer, InlineKeyboard } from "grammy"; import { type Conversation, createConversation } from "@grammyjs/conversations"; import type { BotContext } from "../context"; import { findUser, createUser } from "../../db/users"; import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet"; import { encryptMnemonic } from "../../payments/crypto"; import { publishToRegistry } from "../../payments/registry"; import { createRevealToken, consumeRevealByUserId } from "../../api/onetime"; import { config } from "../../config"; const MIN_PIN_LENGTH = 6; async function onboardingConversation( conversation: Conversation, ctx: BotContext ): Promise { const userId = ctx.from!.id; const chatId = ctx.chat!.id; const username = ctx.from?.username ?? null; // ── Step 1: PIN entry via DM, deleted immediately ───────────────────────── const pinPrompt = await ctx.reply( "Welcome! I'll set up your self-custodial Lightning wallet.\n\n" + "Choose a PIN (min 6 characters). This encrypts your wallet seed.\n" + "*Send your PIN now — I'll delete it immediately after reading:*", { parse_mode: "Markdown" } ); const pinMsg = await conversation.waitFor("message:text"); const pin = pinMsg.message.text.trim(); await Promise.allSettled([ ctx.api.deleteMessage(chatId, pinPrompt.message_id), ctx.api.deleteMessage(chatId, pinMsg.message.message_id), ]); if (pin.length < MIN_PIN_LENGTH) { await ctx.reply(`PIN too short (minimum ${MIN_PIN_LENGTH} characters). Try /start again.`); return; } const confirmPrompt = await ctx.reply("Confirm your PIN:"); const confirmMsg = await conversation.waitFor("message:text"); const confirm = confirmMsg.message.text.trim(); await Promise.allSettled([ ctx.api.deleteMessage(chatId, confirmPrompt.message_id), ctx.api.deleteMessage(chatId, confirmMsg.message.message_id), ]); if (pin !== confirm) { await ctx.reply("PINs do not match. Try /start again."); return; } await ctx.reply("Generating your wallet…"); // ── Step 2: create wallet ────────────────────────────────────────────────── const mnemonic = generateMnemonic(); const sparkAddress = await deriveSparkAddress(mnemonic); const encryptedMnemonic = encryptMnemonic(mnemonic, pin); createUser(userId, username, encryptedMnemonic, sparkAddress); await publishToRegistry(userId, sparkAddress).catch(() => null); // ── Step 3: offer seed reveal choice ────────────────────────────────────── if (config.webappUrl) { const token = createRevealToken(userId, mnemonic); const revealUrl = `${config.webappUrl}?seedToken=${token}#seed`; const keyboard = new InlineKeyboard() .webApp("🔐 View in Mini App (recommended)", revealUrl) .row() .text("💬 Show here in chat (auto-deleted)", "seed_in_chat"); await ctx.reply( "✅ *Wallet created!*\n\n" + "How would you like to view your 12-word recovery seed?\n\n" + "• *Mini App* — shown in a secure web view, never stored in chat\n" + "• *Here in chat* — message deleted automatically after 60 seconds\n\n" + `Your Lightning address: \`${sparkAddress}\``, { parse_mode: "Markdown", reply_markup: keyboard } ); // Conversation ends here — seed_in_chat callback handles the other branch return; } // ── Fallback: no Mini App configured — always show in chat ──────────────── const seedMsg = await ctx.reply( "✅ *Wallet created!*\n\n" + "Your recovery seed (12 words):\n" + `\`${mnemonic}\`\n\n` + "📋 *Write this down offline now.* This message deletes in 60 seconds.\n\n" + `Your Lightning address: \`${sparkAddress}\`\n\n` + "/unlock — unlock wallet to send\n/balance — check balance", { parse_mode: "Markdown" } ); setTimeout(() => { ctx.api.deleteMessage(chatId, seedMsg.message_id).catch(() => null); }, 60_000); } export function registerCommands(bot: Composer): void { bot.use(createConversation(onboardingConversation, "onboarding")); bot.command("start", async (ctx) => { if (ctx.chat.type !== "private") { await ctx.reply("Please DM me to set up your wallet."); return; } const userId = ctx.from!.id; if (findUser(userId)) { await ctx.reply( "You already have a wallet!\n\n" + "/unlock — unlock wallet\n/balance — check balance\n" + "/tip — send sats\n/wallet — open Mini App\n/export — export encrypted seed backup" ); return; } await ctx.conversation.enter("onboarding"); }); // "Show here in chat" branch of the seed reveal choice bot.callbackQuery("seed_in_chat", async (ctx) => { const userId = ctx.from.id; const mnemonic = consumeRevealByUserId(userId); await ctx.answerCallbackQuery(); if (!mnemonic || mnemonic.startsWith("\0")) { await ctx.reply("This seed has already been revealed or expired. Use /export to get your encrypted backup."); return; } const user = findUser(userId); const seedMsg = await ctx.reply( "Your recovery seed (12 words):\n" + `\`${mnemonic}\`\n\n` + "📋 *Write this down offline now.* This message deletes in 60 seconds.", { parse_mode: "Markdown" } ); setTimeout(() => { ctx.api.deleteMessage(ctx.chat!.id, seedMsg.message_id).catch(() => null); }, 60_000); }); bot.command("export", async (ctx) => { if (ctx.chat.type !== "private") { await ctx.reply("Use /export in a private chat with me."); return; } const user = findUser(ctx.from!.id); if (!user) { await ctx.reply("No wallet found. Use /start to create one."); return; } await ctx.reply( "🔐 *Encrypted wallet backup*\n\n" + "```\n" + user.encrypted_mnemonic + "\n```\n\n" + "Decrypt offline with your PIN using AES-256-GCM + scrypt.\n" + "Format: `salt(32B) + iv(12B) + authTag(16B) + ciphertext` — all base64.", { parse_mode: "Markdown" } ); }); }