Files
gbn_ln_bot/src/bot/commands/register.ts
T
goyban e857bf4ec6 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>
2026-05-03 13:21:43 +00:00

172 lines
6.2 KiB
TypeScript

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<BotContext>,
ctx: BotContext
): Promise<void> {
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<BotContext>): 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" }
);
});
}