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,73 @@
|
||||
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<void> {
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user