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 = "

Open this app from Telegram.

"; 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); } }, }; }