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:
goyban
2026-05-03 13:21:43 +00:00
commit e857bf4ec6
40 changed files with 4689 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
import { Composer } from "grammy";
import { type Conversation, createConversation } from "@grammyjs/conversations";
import { randomBytes } from "crypto";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import {
upsertChallenge,
verifyClaim,
deleteClaim,
findVerifiedClaimsForUser,
findClaimByIdentity,
type ClaimedIdType,
} from "../../db/claims";
const CHALLENGE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function generateChallengeCode(): string {
return "spark-verify-" + randomBytes(9).toString("hex"); // 18 hex chars — 72 bits entropy
}
async function claimConversation(
conversation: Conversation<BotContext>,
ctx: BotContext
): Promise<void> {
const userId = ctx.from!.id;
const rawIdentity = (ctx.match as string | undefined)?.trim().replace(/^@/, "");
if (!rawIdentity) {
await ctx.reply("Usage: /claim @username | /claim @channel | /claim @group");
return;
}
if (!findUser(userId)) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
// Prevent overwriting an in-progress challenge by a different user (DoS on claims)
const existing = findClaimByIdentity(rawIdentity);
if (
existing &&
existing.owned_by_user_id !== userId &&
existing.challenge_expiry &&
new Date(existing.challenge_expiry).getTime() > Date.now()
) {
await ctx.reply(
`@${rawIdentity} has an active verification in progress by another user. Try again in a few minutes.`
);
return;
}
const code = generateChallengeCode();
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
await ctx.reply(
`To prove ownership of *@${rawIdentity}*, choose one of:\n\n` +
`*Option A — Alt account / username:*\n` +
`Send the code below *from @${rawIdentity}* in a DM to this bot:\n\`${code}\`\n\n` +
`*Option B — Channel:*\n` +
`Post the code in @${rawIdentity}, then forward that post here.\n\n` +
`*Option C — Group (anonymous admin):*\n` +
`Post the code in the group *as the group identity*, then forward it here.\n\n` +
`Challenge expires in 10 minutes.`,
{ parse_mode: "Markdown" }
);
// Wait for the verification message
const verifyMsg = await conversation.waitFor(["message:text", "message:forward_origin"]);
const msg = verifyMsg.message;
// ── Option A: message sent directly FROM the claimed @username ────────────
// The message must arrive from a DIFFERENT Telegram account whose username
// matches the claimed identity — NOT from the initiating account.
if (msg.text === code) {
const senderUsername = msg.from?.username?.toLowerCase();
const senderUserId = msg.from?.id;
if (senderUserId !== userId && senderUsername === rawIdentity.toLowerCase()) {
// The claimed account sent the code to us directly
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
verifyClaim(rawIdentity);
await ctx.reply(`✅ @${rawIdentity} claimed successfully!`);
return;
}
// Same account sending the code — NOT proof of ownership
await ctx.reply(
`❌ The code must be sent *from @${rawIdentity}*, not from your own account.\n` +
`Try /claim again if the code expired.`,
{ parse_mode: "Markdown" }
);
return;
}
// ── Option B / C: forwarded message from channel or group ─────────────────
type ForwardOrigin = {
type?: string;
chat?: { id?: number; username?: string };
sender_user?: { id?: number };
};
const origin = (msg as { forward_origin?: ForwardOrigin }).forward_origin;
if (!origin) {
await ctx.reply("❌ Could not verify. The message was not sent from @" + rawIdentity + " and was not a forward. Try /claim again.");
return;
}
const forwardedText = (msg as { text?: string }).text?.trim() ?? "";
if (forwardedText !== code) {
await ctx.reply("❌ The forwarded message text does not match the challenge code. Try /claim again.");
return;
}
if (origin.type === "channel") {
const chatUsername = origin.chat?.username?.toLowerCase();
const chatId = String(origin.chat?.id ?? "");
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
upsertChallenge(rawIdentity, "channel", userId, code, expiresAt);
verifyClaim(rawIdentity);
await ctx.reply(`✅ @${rawIdentity} (channel) claimed successfully!`);
return;
}
}
if (origin.type === "chat") {
const chatUsername = origin.chat?.username?.toLowerCase();
const chatId = String(origin.chat?.id ?? "");
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
upsertChallenge(rawIdentity, "group_admin", userId, code, expiresAt);
verifyClaim(rawIdentity);
await ctx.reply(`✅ @${rawIdentity} (group admin) claimed successfully!`);
return;
}
}
await ctx.reply(
"❌ Verification failed — the forward origin did not match @" + rawIdentity + ". Try /claim again."
);
}
export function claimCommands(bot: Composer<BotContext>): void {
bot.use(createConversation(claimConversation, "claim"));
bot.command("claim", async (ctx) => {
if (ctx.chat.type !== "private") {
await ctx.reply("Use /claim in a private chat with me.");
return;
}
await ctx.conversation.enter("claim");
});
bot.command("unclaim", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const identity = ((ctx.match as string | undefined) ?? "").trim().replace(/^@/, "");
if (!identity) {
await ctx.reply("Usage: /unclaim @identity");
return;
}
const removed = deleteClaim(identity, userId);
await ctx.reply(removed ? `✅ @${identity} unclaimed.` : `No claim on @${identity} found.`);
});
bot.command("identities", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const claims = findVerifiedClaimsForUser(userId);
if (claims.length === 0) {
await ctx.reply("No claimed identities. Use /claim @username to add one.");
return;
}
const lines = claims.map(
(c) => `• @${c.claimed_id} (${c.claimed_id_type}) — spending: ${c.allow_spending ? "on" : "off"}`
);
await ctx.reply(lines.join("\n"));
});
}
+110
View File
@@ -0,0 +1,110 @@
import { Composer } from "grammy";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import { upsertContact, deleteContact, listContacts } from "../../db/contacts";
import { getDb } from "../../db/schema";
export function contactCommands(bot: Composer<BotContext>): void {
bot.command("contact", async (ctx) => {
const senderId = ctx.from?.id;
if (!senderId) return;
if (!findUser(senderId)) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
const subcommand = args[0]?.toLowerCase();
switch (subcommand) {
case "add":
await handleAdd(ctx, senderId, args.slice(1));
break;
case "list":
await handleList(ctx, senderId);
break;
case "remove":
await handleRemove(ctx, senderId, args[1]);
break;
default:
await ctx.reply(
"Usage:\n" +
"/contact add <alias> <@username|lightning_address>\n" +
"/contact list\n" +
"/contact remove <alias>"
);
}
});
}
async function handleAdd(ctx: BotContext, ownerId: number, args: string[]): Promise<void> {
const [alias, target] = args;
if (!alias || !target) {
await ctx.reply("Usage: /contact add <alias> <@username|lightning_address>");
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
await ctx.reply("Alias must contain only letters, numbers, underscores, or hyphens.");
return;
}
const cleanTarget = target.replace(/^@/, "");
const numericId = Number(cleanTarget);
let targetUserId: number | null = null;
let targetAddress: string | null = null;
if (!isNaN(numericId)) {
targetUserId = numericId;
const user = findUser(numericId);
targetAddress = user?.spark_address ?? null;
} else if (cleanTarget.includes("@") || cleanTarget.includes(".")) {
// Looks like a Lightning address — store as-is
targetAddress = cleanTarget;
} else {
// Username lookup
const row = getDb()
.prepare("SELECT id, spark_address FROM users WHERE LOWER(username) = ?")
.get(cleanTarget.toLowerCase()) as { id: number; spark_address: string | null } | undefined;
if (row) {
targetUserId = row.id;
targetAddress = row.spark_address ?? null;
} else {
await ctx.reply(`User @${cleanTarget} is not registered with this bot.`);
return;
}
}
upsertContact(ownerId, alias, targetUserId, targetAddress);
await ctx.reply(`✅ Contact saved: "${alias}" → ${target}`);
}
async function handleList(ctx: BotContext, ownerId: number): Promise<void> {
const contacts = listContacts(ownerId);
if (contacts.length === 0) {
await ctx.reply("No contacts saved. Add one with /contact add <alias> <@username>");
return;
}
const lines = contacts.map((c) => {
const dest = c.target_address ?? `user #${c.target_user_id}`;
return `• *${c.alias}* → ${dest}`;
});
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
}
async function handleRemove(ctx: BotContext, ownerId: number, alias?: string): Promise<void> {
if (!alias) {
await ctx.reply("Usage: /contact remove <alias>");
return;
}
const removed = deleteContact(ownerId, alias);
await ctx.reply(removed ? `✅ Contact "${alias}" removed.` : `No contact named "${alias}" found.`);
}
+171
View File
@@ -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" }
);
});
}
+164
View File
@@ -0,0 +1,164 @@
import { Composer } from "grammy";
import type { BotContext } from "../context";
import { findUser, updateUnlockDuration } from "../../db/users";
import { setAllowSpending, findClaimByIdentity } from "../../db/claims";
import { publishToRegistry } from "../../payments/registry";
import { getDb } from "../../db/schema";
const DURATION_MAP: Record<string, number> = {
"15m": 15 * 60,
"30m": 30 * 60,
"1h": 3600,
"4h": 4 * 3600,
"8h": 8 * 3600,
"24h": 24 * 3600,
"48h": 48 * 3600,
};
export function settingsCommands(bot: Composer<BotContext>): void {
bot.command("settings", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const user = findUser(userId);
if (!user) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
const key = args[0]?.toLowerCase();
if (!key) {
await ctx.reply(
"Settings:\n" +
"/settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx> — default unlock window\n" +
"/settings registry <on|off> — federated registry opt-in\n" +
`/settings spending <@identity> <on|off> — allow/deny spending from identity\n\n` +
`Current unlock duration: ${formatDuration(user.unlock_duration)}`
);
return;
}
switch (key) {
case "unlock_duration":
await handleUnlockDuration(ctx, userId, args[1]);
break;
case "registry":
await handleRegistry(ctx, userId, user, args[1]);
break;
case "spending":
await handleSpending(ctx, userId, args[1], args[2]);
break;
default:
await ctx.reply(`Unknown setting: ${key}`);
}
});
bot.command("history", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const rows = getDb()
.prepare(
`SELECT id, amount_sats, status, recipient_address, created_at
FROM pending_transactions
WHERE initiator_user_id = ?
ORDER BY created_at DESC LIMIT 10`
)
.all(userId) as {
id: string;
amount_sats: number;
status: string;
recipient_address: string | null;
created_at: string;
}[];
if (rows.length === 0) {
await ctx.reply("No transaction history yet.");
return;
}
const lines = rows.map((r) => {
const statusIcon = r.status === "done" ? "✅" : r.status === "failed" ? "❌" : "⏳";
const dest = r.recipient_address?.slice(0, 20) ?? "unknown";
return `${statusIcon} ${r.amount_sats} sats → ${dest}… (${r.created_at.slice(0, 10)})`;
});
await ctx.reply(lines.join("\n"));
});
}
async function handleUnlockDuration(ctx: BotContext, userId: number, value?: string): Promise<void> {
if (!value) {
await ctx.reply("Usage: /settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx>");
return;
}
const lower = value.toLowerCase();
if (lower === "1tx") {
// Store 0 as a sentinel for tx-only mode preference
updateUnlockDuration(userId, 0);
await ctx.reply("Default unlock mode set to: 1 transaction.");
return;
}
const seconds = DURATION_MAP[lower];
if (!seconds) {
await ctx.reply(`Unknown duration. Choose one of: ${Object.keys(DURATION_MAP).join(", ")}, 1tx`);
return;
}
updateUnlockDuration(userId, seconds);
await ctx.reply(`Default unlock duration set to ${value}.`);
}
async function handleRegistry(
ctx: BotContext,
userId: number,
user: { spark_address: string | null },
value?: string
): Promise<void> {
if (value === "on") {
if (user.spark_address) {
await publishToRegistry(userId, user.spark_address);
}
await ctx.reply("✅ Federated registry publishing enabled.");
} else if (value === "off") {
// TODO: send a delete request to the remote registry if supported
await ctx.reply("Registry publishing disabled. Your entry remains in remote caches until they expire.");
} else {
await ctx.reply("Usage: /settings registry on|off");
}
}
async function handleSpending(
ctx: BotContext,
userId: number,
identity?: string,
value?: string
): Promise<void> {
if (!identity || !value) {
await ctx.reply("Usage: /settings spending <@identity> <on|off>");
return;
}
const clean = identity.replace(/^@/, "");
const claim = findClaimByIdentity(clean);
if (!claim || claim.owned_by_user_id !== userId || !claim.verified_at) {
await ctx.reply(`No verified claim on @${clean}. Use /claim first.`);
return;
}
const allow = value.toLowerCase() === "on";
setAllowSpending(clean, userId, allow);
await ctx.reply(`Spending from @${clean}: ${allow ? "enabled" : "disabled"}.`);
}
function formatDuration(seconds: number): string {
if (seconds === 0) return "1tx";
if (seconds < 3600) return `${seconds / 60}m`;
return `${seconds / 3600}h`;
}
+207
View File
@@ -0,0 +1,207 @@
import { Composer } from "grammy";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import { getSession, consumeTx } from "../../payments/session";
import { sendPayment } from "../../payments/wallet";
import { resolveRecipient } from "../../payments/registry";
import { createPending, findPending, updateStatus, claimForRecipient, type PendingTransaction } from "../../db/pending";
// ─── Core payment execution ────────────────────────────────────────────────────
export async function executePay(
userId: number,
recipientAddress: string,
amountSats: number
): Promise<{ txId: string; feeSats: number }> {
const session = getSession(userId);
if (!session) throw new Error("wallet_locked");
const result = await sendPayment(userId, recipientAddress, amountSats);
consumeTx(userId);
return result;
}
// ─── Process a queued pending transaction ─────────────────────────────────────
export async function processPending(ctx: BotContext, tx: PendingTransaction): Promise<void> {
if (!tx.recipient_address) {
updateStatus(tx.id, "failed");
return;
}
updateStatus(tx.id, "processing");
try {
await executePay(tx.initiator_user_id, tx.recipient_address, tx.amount_sats);
updateStatus(tx.id, "done");
// Confirm back in the group if we know which chat
if (tx.group_chat_id) {
try {
const recipientLabel =
tx.recipient_user_id ? `user #${tx.recipient_user_id}` : tx.recipient_address;
await ctx.api.sendMessage(
tx.group_chat_id,
`${tx.amount_sats} sats sent to ${recipientLabel}`
);
} catch {
// group message is best-effort
}
}
} catch (err) {
updateStatus(tx.id, "failed");
console.error("[tip] processPending failed:", err);
}
}
// ─── /tip command ─────────────────────────────────────────────────────────────
export function tipCommands(bot: Composer<BotContext>): void {
bot.command("tip", async (ctx) => {
const senderId = ctx.from?.id;
if (!senderId) return;
const sender = findUser(senderId);
if (!sender) {
await ctx.reply("You need a wallet first. DM me /start to set one up.");
return;
}
// Parse amount (and optional target) from command args
const args = (ctx.match as string | undefined)?.trim().split(/\s+/) ?? [];
const amountSats = args[0] ? parseInt(args[0], 10) : NaN;
const MAX_SATS = 21_000_000 * 100_000_000; // 21M BTC in sats
if (isNaN(amountSats) || amountSats <= 0 || amountSats > MAX_SATS) {
await ctx.reply("Usage: /tip <amount_sats> [@user|alias]\nExample: /tip 100");
return;
}
const targetArg = args[1] ?? null;
const replyToUserId = ctx.message?.reply_to_message?.from?.id;
const groupChatId = ctx.chat?.type !== "private" ? ctx.chat?.id ?? null : null;
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
senderId,
targetArg,
replyToUserId
);
if (!recipientAddress) {
await ctx.reply(
"Recipient not found or not registered. Share this link with them: t.me/" +
ctx.me.username
);
return;
}
const session = getSession(senderId);
if (!session) {
// Queue the transaction and ask user to unlock
const txId = createPending({
initiatorUserId: senderId,
recipientUserId: recipientUserId,
recipientAddress,
amountSats,
initiatedVia: "command",
groupChatId,
});
await ctx.reply(
`Wallet is locked. DM me /unlock to send ${amountSats} sats.\nTransaction queued (expires in 2 min, ref: \`${txId.slice(0, 8)}\`).`,
{ parse_mode: "Markdown" }
);
// DM the sender if we're in a group
if (groupChatId) {
try {
await ctx.api.sendMessage(
senderId,
`You have a pending tip of ${amountSats} sats. /unlock to confirm.`
);
} catch {
// DM might be blocked — the group message already told them
}
}
return;
}
// Wallet unlocked — send immediately
try {
await executePay(senderId, recipientAddress, amountSats);
const recipientLabel = replyToUserId
? `@${ctx.message?.reply_to_message?.from?.username ?? replyToUserId}`
: targetArg ?? recipientAddress;
await ctx.reply(`${amountSats} sats sent to ${recipientLabel}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown error";
await ctx.reply(`❌ Payment failed: ${msg}`);
}
});
// Callback handler for claim button (inline mode without alias)
bot.callbackQuery(/^claim:(.+)$/, async (ctx) => {
const txId = ctx.match[1];
const claimerId = ctx.from.id;
// Reject self-claims: sender should not claim their own tip
const txCheck = findPending(txId);
if (txCheck?.initiator_user_id === claimerId) {
await ctx.answerCallbackQuery("You cannot claim your own tip.");
return;
}
const claimer = findUser(claimerId);
if (!claimer?.spark_address) {
await ctx.answerCallbackQuery({
text: "You need a wallet to claim this. DM me /start to set one up.",
show_alert: true,
});
return;
}
// Atomic claim: only one concurrent caller can succeed (TOCTOU-safe)
const won = claimForRecipient(txId, claimerId, claimer.spark_address);
if (!won) {
await ctx.answerCallbackQuery("This tip has already been claimed or expired.");
return;
}
// We now own the tx exclusively (status = 'processing' in DB)
const tx = findPending(txId)!;
const session = getSession(tx.initiator_user_id);
if (!session) {
// Revert to awaiting_unlock so the sender can still pay on /unlock
updateStatus(txId, "awaiting_unlock");
await ctx.answerCallbackQuery("Sender needs to unlock their wallet first.");
try {
await ctx.api.sendMessage(
tx.initiator_user_id,
`@${ctx.from.username ?? claimerId} wants to claim your ${tx.amount_sats} sat tip. /unlock to send.`
);
} catch {
// best-effort DM
}
return;
}
try {
await executePay(tx.initiator_user_id, claimer.spark_address, tx.amount_sats);
updateStatus(txId, "done");
await ctx.editMessageText(
`${tx.amount_sats} sats claimed by @${ctx.from.username ?? claimerId}`
);
await ctx.answerCallbackQuery("Payment sent!");
} catch {
updateStatus(txId, "failed");
await ctx.answerCallbackQuery("Payment failed. Try again later.");
}
});
}
+156
View File
@@ -0,0 +1,156 @@
import { Composer } from "grammy";
import { type Conversation, createConversation } from "@grammyjs/conversations";
import type { BotContext } from "../context";
import { findUser, updateUnlockDuration } from "../../db/users";
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../../api/ratelimit";
import { getWalletInfo } from "../../payments/wallet";
import { decryptMnemonic } from "../../payments/crypto";
import { createSession, destroySession, getSession } from "../../payments/session";
import { connectWallet, disconnectWallet } from "../../payments/wallet";
import { findPendingForUser } from "../../db/pending";
import { processPending } from "./tip";
function parseDuration(raw: string): { seconds: number; policy: "timed" | "tx-only"; txCount?: number } | null {
const txMatch = raw.match(/^(\d+)?tx$/i);
if (txMatch) {
const count = txMatch[1] ? parseInt(txMatch[1], 10) : 1;
return { seconds: 86400, policy: "tx-only", txCount: count };
}
const timeMatch = raw.match(/^(\d+)(s|m|h|d)$/i);
if (!timeMatch) return null;
const value = parseInt(timeMatch[1], 10);
const unit = timeMatch[2].toLowerCase();
const multiplier: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
return { seconds: value * multiplier[unit], policy: "timed" };
}
async function unlockConversation(
conversation: Conversation<BotContext>,
ctx: BotContext
): Promise<void> {
const userId = ctx.from!.id;
const user = findUser(userId);
if (!user) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
const args = ctx.match as string | undefined;
let durationSeconds = user.unlock_duration;
let policy: "timed" | "tx-only" = "timed";
let txCount: number | undefined;
if (args?.trim()) {
const parsed = parseDuration(args.trim());
if (!parsed) {
await ctx.reply("Unknown duration format. Examples: /unlock 4h | /unlock 1tx | /unlock 30m");
return;
}
({ seconds: durationSeconds, policy } = parsed);
txCount = parsed.txCount;
}
try {
checkPinRateLimit(userId);
} catch (err) {
if (err instanceof RateLimitError) {
await ctx.reply(`🚫 ${err.message}`);
return;
}
throw err;
}
const chatId = ctx.chat!.id;
const pinPrompt = await ctx.reply(
"🔐 Enter your PIN — I'll delete it immediately after reading:"
);
const pinMsg = await conversation.waitFor("message:text");
const pin = pinMsg.message.text.trim();
// Delete both the prompt and the user's PIN before doing anything else
await Promise.allSettled([
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
]);
let mnemonic: string;
try {
mnemonic = decryptMnemonic(user.encrypted_mnemonic, pin);
recordPinSuccess(userId);
} catch {
recordPinFailure(userId);
await ctx.reply("❌ Incorrect PIN.");
return;
}
await connectWallet(userId, mnemonic);
createSession(userId, mnemonic, policy, durationSeconds, txCount);
const label =
policy === "tx-only"
? `${txCount ?? 1} transaction(s)`
: durationSeconds >= 3600
? `${durationSeconds / 3600}h`
: `${durationSeconds / 60}m`;
await ctx.reply(`✅ Wallet unlocked for ${label}.`);
// Process any pending transactions
const pending = findPendingForUser(userId);
if (pending.length > 0) {
await ctx.reply(`Processing ${pending.length} pending transaction(s)...`);
for (const tx of pending) {
await processPending(ctx, tx);
}
}
}
export function unlockCommands(bot: Composer<BotContext>): void {
bot.use(createConversation(unlockConversation, "unlock"));
bot.command("unlock", async (ctx) => {
if (ctx.chat.type !== "private") {
await ctx.reply("Please use /unlock in a private chat with me (for PIN security).");
return;
}
await ctx.conversation.enter("unlock");
});
bot.command("lock", async (ctx) => {
const userId = ctx.from!.id;
const had = destroySession(userId);
await disconnectWallet(userId);
if (had) {
await ctx.reply("🔒 Wallet locked.");
} else {
await ctx.reply("Wallet is already locked.");
}
});
bot.command("balance", async (ctx) => {
const userId = ctx.from!.id;
const session = getSession(userId);
if (!session) {
await ctx.reply("Wallet is locked. Use /unlock first.");
return;
}
const info = await getWalletInfo(userId);
if (!info) {
await ctx.reply("Could not fetch balance. Try /unlock again.");
return;
}
await ctx.reply(
`⚡ Balance: *${info.balanceSats} sats*\nAddress: \`${info.sparkAddress}\``,
{ parse_mode: "Markdown" }
);
});
}
+8
View File
@@ -0,0 +1,8 @@
import { Context, SessionFlavor } from "grammy";
import { ConversationFlavor } from "@grammyjs/conversations";
export interface SessionData {
// intentionally empty — conversations plugin manages its own state
}
export type BotContext = Context & SessionFlavor<SessionData> & ConversationFlavor;
+131
View File
@@ -0,0 +1,131 @@
import { Bot, InlineKeyboard, session } from "grammy";
import { conversations } from "@grammyjs/conversations";
import { run } from "@grammyjs/runner";
import { config } from "../config";
import { getDb } from "../db/schema";
import { getAllUserIds, findUser, updateUsername } from "../db/users";
import { expireStale } from "../db/pending";
import { startSweep, stopSweep, destroySession, getAllActiveUserIds } from "../payments/session";
import { disconnectWallet } from "../payments/wallet";
import { startApiServer } from "../api/server";
import type { BotContext, SessionData } from "./context";
import { registerCommands } from "./commands/register";
import { unlockCommands } from "./commands/unlock";
import { tipCommands } from "./commands/tip";
import { contactCommands } from "./commands/contact";
import { claimCommands } from "./commands/claim";
import { settingsCommands } from "./commands/settings";
import { inlineTipHandler } from "./inline/tip";
// ─── Bootstrap ────────────────────────────────────────────────────────────────
getDb();
const bot = new Bot<BotContext>(config.botToken);
// ─── Middleware ───────────────────────────────────────────────────────────────
bot.use(session<SessionData, BotContext>({ initial: () => ({}) }));
bot.use(conversations());
// Keep username up-to-date on every interaction
bot.use(async (ctx, next) => {
const userId = ctx.from?.id;
if (userId) {
const user = findUser(userId);
if (user && ctx.from?.username !== user.username) {
updateUsername(userId, ctx.from?.username ?? null);
}
}
await next();
});
// ─── Commands ─────────────────────────────────────────────────────────────────
registerCommands(bot);
unlockCommands(bot);
tipCommands(bot);
contactCommands(bot);
claimCommands(bot);
settingsCommands(bot);
inlineTipHandler(bot);
// /wallet — open the Mini App
bot.command("wallet", async (ctx) => {
const webappUrl = config.webappUrl;
if (!webappUrl) {
await ctx.reply("Mini App is not configured. Set WEBAPP_URL in the bot environment.");
return;
}
const keyboard = new InlineKeyboard().webApp("Open Wallet", webappUrl);
await ctx.reply("Tap to open your wallet:", { reply_markup: keyboard });
});
// ─── Error handler ────────────────────────────────────────────────────────────
bot.catch((err) => {
console.error("[bot] Unhandled error:", err.message);
console.error(err.error);
});
// ─── Session sweep ────────────────────────────────────────────────────────────
startSweep(async (userId) => {
await disconnectWallet(userId).catch(() => null);
console.log(`[session] Expired for user ${userId}`);
});
// ─── Stale pending transaction cleanup ────────────────────────────────────────
setInterval(() => {
const count = expireStale();
if (count > 0) console.log(`[pending] Expired ${count} stale transaction(s)`);
}, 60_000).unref?.();
// ─── Startup notification ─────────────────────────────────────────────────────
async function notifyUsersOnRestart(): Promise<void> {
const userIds = getAllUserIds();
const message = "🔄 Bot restarted — your wallet session was cleared. /unlock to continue.";
for (const userId of userIds) {
try {
await bot.api.sendMessage(userId, message);
} catch {
// User may have blocked the bot or not started it yet
}
}
}
// ─── Graceful shutdown ────────────────────────────────────────────────────────
async function shutdown(runner: { stop(): void }): Promise<void> {
console.log("[bot] Shutting down...");
runner.stop();
stopSweep();
for (const userId of getAllActiveUserIds()) {
destroySession(userId);
await disconnectWallet(userId).catch(() => null);
}
getDb().close();
process.exit(0);
}
// ─── Start ────────────────────────────────────────────────────────────────────
const runner = run(bot);
process.once("SIGINT", () => shutdown(runner));
process.once("SIGTERM", () => shutdown(runner));
// Start the Mini App API server alongside the bot
startApiServer().catch((err) => {
console.error("[api] Failed to start:", err.message);
process.exit(1);
});
console.log(`[bot] ${config.botInstanceName} started.`);
notifyUsersOnRestart().catch((err) =>
console.warn("[bot] Restart notification error:", err.message)
);
+167
View File
@@ -0,0 +1,167 @@
import { Composer, InlineKeyboard } from "grammy";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import { getSession } from "../../payments/session";
import { resolveRecipient } from "../../payments/registry";
import { createPending, findPending, updateStatus } from "../../db/pending";
import { executePay } from "../commands/tip";
// ─── Inline query: @mybot tip 100 [alias] ────────────────────────────────────
export function inlineTipHandler(bot: Composer<BotContext>): void {
bot.on("inline_query", async (ctx) => {
const senderId = ctx.from.id;
const text = ctx.inlineQuery.query.trim();
// Only handle "tip <amount> [alias]" pattern
const match = text.match(/^tip\s+(\d+)(?:\s+(\S+))?$/i);
if (!match) {
await ctx.answerInlineQuery([], {
cache_time: 0,
button: { text: "How to use: tip <amount> [alias]", start_parameter: "help" },
});
return;
}
const amountSats = parseInt(match[1], 10);
const alias = match[2] ?? null;
if (!findUser(senderId)) {
await ctx.answerInlineQuery([
{
type: "article",
id: "not_registered",
title: "Wallet not set up",
description: "DM me /start to create your wallet",
input_message_content: { message_text: "I need to set up my wallet first." },
},
]);
return;
}
if (!alias) {
// No alias — post a "claim" message with a button
const txId = createPending({
initiatorUserId: senderId,
recipientUserId: null,
recipientAddress: null,
amountSats,
initiatedVia: "inline",
groupChatId: null, // resolved in chosen_inline_result
});
const keyboard = new InlineKeyboard().text(`⚡ Claim ${amountSats} sats`, `claim:${txId}`);
await ctx.answerInlineQuery([
{
type: "article",
id: `tip_claim_${txId}`,
title: `⚡ Send ${amountSats} sats — anyone can claim`,
description: "Tap to post. First person to tap Claim receives the payment.",
input_message_content: {
message_text: `${amountSats} sats up for grabs — tap to claim!`,
},
reply_markup: keyboard,
},
]);
return;
}
// Resolve alias to an address
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
senderId,
alias
);
if (!recipientAddress) {
await ctx.answerInlineQuery([
{
type: "article",
id: "alias_not_found",
title: `Unknown alias: ${alias}`,
description: "Add contacts with /contact add",
input_message_content: {
message_text: `Could not find alias "${alias}". Add contacts with /contact add.`,
},
},
]);
return;
}
const txId = createPending({
initiatorUserId: senderId,
recipientUserId,
recipientAddress,
amountSats,
initiatedVia: "inline",
groupChatId: null,
});
await ctx.answerInlineQuery([
{
type: "article",
id: `tip_direct_${txId}`,
title: `⚡ Send ${amountSats} sats to ${alias}`,
description: "Tap to send",
input_message_content: {
message_text: `⚡ Sending ${amountSats} sats to ${alias}`,
},
},
]);
});
// ─── Chosen inline result: actually execute the payment ──────────────────
bot.on("chosen_inline_result", async (ctx) => {
const resultId = ctx.chosenInlineResult.result_id;
const senderId = ctx.from.id;
// Only handle direct tip results (not claim results — those use callback buttons)
if (!resultId.startsWith("tip_direct_")) return;
const txId = resultId.replace("tip_direct_", "");
const tx = findPending(txId);
if (!tx || tx.status !== "awaiting_unlock") return;
const session = getSession(senderId);
if (!session) {
// Wallet locked — update tx and notify sender
updateStatus(txId, "awaiting_unlock"); // already set, but make explicit
try {
await ctx.api.sendMessage(
senderId,
`Wallet locked. /unlock to send ${tx.amount_sats} sats (pending for 2 min).`
);
} catch {
// DM might be blocked
}
return;
}
updateStatus(txId, "processing");
try {
await executePay(senderId, tx.recipient_address!, tx.amount_sats);
updateStatus(txId, "done");
// Edit the posted message to show success (requires inline_message_id)
const inlineMsgId = ctx.chosenInlineResult.inline_message_id;
if (inlineMsgId) {
try {
await ctx.api.editMessageTextInline(
inlineMsgId,
`${tx.amount_sats} sats sent!`
);
} catch {
// best-effort edit
}
}
} catch {
updateStatus(txId, "failed");
}
});
}