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,171 @@
|
||||
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" }
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user