e857bf4ec6
- 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>
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
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);
|
|
}
|
|
},
|
|
};
|
|
}
|