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
+326
View File
@@ -0,0 +1,326 @@
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
function walletApp() {
return {
// ── State ──────────────────────────────────────────────────────────────
tab: "dashboard",
botName: tg.initDataUnsafe?.bot?.username ?? "Wallet",
toastVisible: false,
wallet: {
registered: false,
locked: true,
balanceSats: 0,
sparkAddress: "",
unlockExpiresAt: null,
sessionPolicy: null,
},
// Setup (first launch, no wallet)
setup: {
pin: "",
confirmPin: "",
error: "",
creating: false,
},
// Seed reveal
seed: {
screen: false,
loading: false,
words: [],
error: "",
confirmed: false,
},
// PIN unlock overlay
showPinOverlay: false,
pin: "",
pinError: "",
unlocking: false,
// Send
send: {
destination: "",
amountSats: "",
description: "",
detectedType: "",
sending: false,
error: "",
success: "",
},
// Receive
receive: {
tab: "spark",
sparkAddress: "",
lightningInvoice: null,
onchainAddress: null,
amountSats: "",
},
// History
history: {
items: [],
loading: false,
limit: 20,
offset: 0,
},
// ── Init ───────────────────────────────────────────────────────────────
async init() {
if (!tg.initData) {
document.body.innerHTML = "<p style='padding:2rem;color:red'>Open this app from Telegram.</p>";
return;
}
// Seed reveal via one-time token (from bot onboarding link)
const params = new URLSearchParams(window.location.search);
const seedToken = params.get("seedToken");
if (seedToken && window.location.hash === "#seed") {
await this.revealSeed(seedToken);
return;
}
await this.refresh();
},
// ── API ────────────────────────────────────────────────────────────────
async api(method, path, body) {
const opts = {
method,
headers: {
"Content-Type": "application/json",
"X-Init-Data": tg.initData,
},
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Request failed");
return data;
},
// ── Wallet state ───────────────────────────────────────────────────────
async refresh() {
try {
const data = await this.api("GET", "/api/wallet");
if (!data.registered) {
this.tab = "setup";
return;
}
this.wallet = data;
this.receive.sparkAddress = data.sparkAddress;
this.$nextTick(() => this.renderQr("qr-spark", data.sparkAddress));
} catch (e) {
console.error("refresh:", e.message);
}
},
// ── Setup — create wallet ──────────────────────────────────────────────
async createWallet() {
this.setup.error = "";
if (this.setup.pin.length < 6) {
this.setup.error = "PIN must be at least 6 characters.";
return;
}
if (this.setup.pin !== this.setup.confirmPin) {
this.setup.error = "PINs do not match.";
return;
}
this.setup.creating = true;
try {
const data = await this.api("POST", "/api/setup", { pin: this.setup.pin });
this.setup.pin = "";
this.setup.confirmPin = "";
// Show seed reveal screen immediately after creation
this.seed.words = data.words;
this.seed.screen = true;
this.seed.confirmed = false;
// Pre-load wallet state for after seed is dismissed
this.wallet.registered = true;
this.wallet.sparkAddress = data.sparkAddress;
this.receive.sparkAddress = data.sparkAddress;
} catch (e) {
this.setup.error = e.message;
} finally {
this.setup.creating = false;
}
},
// ── Seed reveal — via one-time URL token (from bot link) ───────────────
async revealSeed(token) {
this.seed.screen = true;
this.seed.loading = true;
try {
const data = await this.api("GET", `/api/seed/reveal?token=${encodeURIComponent(token)}`);
this.seed.words = data.words;
} catch (e) {
this.seed.error = e.message || "Link already used or expired.";
} finally {
this.seed.loading = false;
}
},
async closeSeedScreen() {
this.seed.words = [];
this.seed.screen = false;
this.seed.confirmed = false;
history.replaceState(null, "", window.location.pathname);
await this.refresh();
},
// ── Unlock / Lock ──────────────────────────────────────────────────────
async unlock() {
if (!this.pin) return;
this.unlocking = true;
this.pinError = "";
try {
await this.api("POST", "/api/unlock", { pin: this.pin });
this.showPinOverlay = false;
this.pin = "";
await this.refresh();
} catch (e) {
this.pinError = e.message;
} finally {
this.unlocking = false;
}
},
async lock() {
await this.api("POST", "/api/lock");
await this.refresh();
},
// ── Send ───────────────────────────────────────────────────────────────
detectType() {
const d = this.send.destination.trim().toLowerCase();
if (!d) { this.send.detectedType = ""; return; }
if (d.startsWith("lnbc") || d.startsWith("lntb") || d.startsWith("lnurl")) {
this.send.detectedType = "⚡ Lightning";
} else if (d.startsWith("spark1") || d.includes("@")) {
this.send.detectedType = "✳️ Spark";
} else {
this.send.detectedType = "₿ On-chain";
}
},
async sendPayment() {
this.send.error = "";
this.send.success = "";
if (!this.send.destination.trim()) { this.send.error = "Enter a destination."; return; }
this.send.sending = true;
try {
const body = {
destination: this.send.destination.trim(),
amountSats: this.send.amountSats ? parseInt(this.send.amountSats, 10) : undefined,
description: this.send.description || undefined,
};
const res = await this.api("POST", "/api/send", body);
this.send.success = `✅ Sent! Fee: ${res.feeSats} sats`;
this.send.destination = "";
this.send.amountSats = "";
this.send.description = "";
this.send.detectedType = "";
await this.refresh();
} catch (e) {
if (e.message === "wallet_locked") {
this.showPinOverlay = true;
} else {
this.send.error = e.message;
}
} finally {
this.send.sending = false;
}
},
// ── Receive ────────────────────────────────────────────────────────────
async loadReceive() {
const params = new URLSearchParams();
if (this.receive.amountSats) params.set("amount", this.receive.amountSats);
try {
const data = await this.api("GET", `/api/receive?${params}`);
this.receive.sparkAddress = data.sparkAddress;
this.receive.lightningInvoice = data.lightningInvoice;
this.receive.onchainAddress = data.onchainAddress;
this.$nextTick(() => {
this.renderQr("qr-spark", data.sparkAddress);
if (data.lightningInvoice) this.renderQr("qr-lightning", data.lightningInvoice);
if (data.onchainAddress) this.renderQr("qr-onchain", data.onchainAddress);
});
} catch (e) {
console.error("loadReceive:", e.message);
}
},
// ── History ────────────────────────────────────────────────────────────
async loadHistory() {
this.history.loading = true;
this.history.offset = 0;
try {
const data = await this.api("GET", `/api/history?limit=${this.history.limit}&offset=0`);
this.history.items = data.transactions;
} catch (e) {
console.error("loadHistory:", e.message);
} finally {
this.history.loading = false;
}
},
async loadMore() {
this.history.offset += this.history.limit;
try {
const data = await this.api("GET", `/api/history?limit=${this.history.limit}&offset=${this.history.offset}`);
this.history.items.push(...data.transactions);
} catch (e) {
console.error("loadMore:", e.message);
}
},
// ── QR ─────────────────────────────────────────────────────────────────
renderQr(canvasId, text) {
if (!text) return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
QRCode.toCanvas(canvas, text, { width: 220, margin: 2, color: { dark: "#000", light: "#fff" } });
},
// ── Helpers ────────────────────────────────────────────────────────────
satsToBtc(sats) { return (sats / 1e8).toFixed(8); },
formatExpiry(iso) {
if (!iso) return "";
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
},
formatDate(iso) {
if (!iso) return "";
const d = new Date(iso + "Z");
return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
},
txIcon(status) {
return { done: "✅", failed: "❌", processing: "⏳", expired: "💨", awaiting_unlock: "🔒" }[status] ?? "•";
},
async copyText(text) {
try {
await navigator.clipboard.writeText(text);
this.toastVisible = true;
setTimeout(() => { this.toastVisible = false; }, 1500);
} catch {
tg.showAlert("Copy: " + text);
}
},
};
}