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