Files
goyban e857bf4ec6 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>
2026-05-03 13:21:43 +00:00

334 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Lightning Wallet</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<link rel="stylesheet" href="style.css" />
</head>
<body x-data="walletApp()" x-init="init()" class="app">
<!-- ── Setup screen (no wallet yet) ─────────────────────────────────── -->
<div x-show="tab === 'setup'" class="setup-screen">
<div class="setup-inner">
<div class="setup-icon"></div>
<h1 class="setup-title">Create Your Wallet</h1>
<p class="setup-sub">
Choose a PIN to encrypt your wallet seed.<br>
Your PIN is never stored — only you know it.
</p>
<label class="field-label">PIN <span class="hint">(min 6 characters)</span></label>
<input
x-model="setup.pin"
type="password"
placeholder="Enter PIN"
class="field-input"
autocomplete="new-password"
@keydown.enter="$refs.confirmPin.focus()"
/>
<label class="field-label" style="margin-top:12px">Confirm PIN</label>
<input
x-ref="confirmPin"
x-model="setup.confirmPin"
type="password"
placeholder="Repeat PIN"
class="field-input"
autocomplete="new-password"
@keydown.enter="createWallet()"
/>
<div x-show="setup.error" class="error-msg" x-text="setup.error"></div>
<button
@click="createWallet()"
class="btn-primary btn-wide"
:disabled="setup.creating || !setup.pin || !setup.confirmPin"
style="margin-top:20px"
>
<span x-show="!setup.creating">Create Wallet</span>
<span x-show="setup.creating">Creating…</span>
</button>
</div>
</div>
<!-- ── Seed reveal screen ────────────────────────────────────────────── -->
<div x-show="seed.screen" class="seed-screen" x-transition>
<div x-show="seed.loading" class="seed-loading">Loading your recovery seed…</div>
<template x-if="seed.error">
<div class="seed-error-wrap">
<p class="seed-error-icon">⚠️</p>
<p class="seed-error-msg" x-text="seed.error"></p>
<p class="hint" style="margin-top:8px">Close this window and contact the bot.</p>
</div>
</template>
<template x-if="!seed.loading && !seed.error && seed.words.length">
<div class="seed-content">
<h1 class="seed-title">Your Recovery Seed</h1>
<p class="seed-subtitle">
Write these 12 words down on paper and store them offline.<br>
<strong>Anyone with these words can access your funds.</strong>
</p>
<ol class="seed-grid">
<template x-for="(word, i) in seed.words" :key="i">
<li class="seed-word">
<span class="seed-num" x-text="i + 1"></span>
<span class="seed-val" x-text="word"></span>
</li>
</template>
</ol>
<div class="seed-confirm-wrap">
<label class="seed-check">
<input type="checkbox" x-model="seed.confirmed" />
I have written down all 12 words in the correct order.
</label>
</div>
<button
@click="closeSeedScreen()"
class="btn-primary btn-wide"
:disabled="!seed.confirmed"
x-show="seed.confirmed"
x-transition
>
Done — open my wallet
</button>
</div>
</template>
</div>
<!-- ── PIN unlock overlay ───────────────────────────────────────────── -->
<div x-show="showPinOverlay" class="overlay" x-transition>
<div class="pin-card">
<h2 class="pin-title">Unlock Wallet</h2>
<p class="pin-hint">Enter your PIN to unlock</p>
<input
x-model="pin"
type="password"
placeholder="PIN"
class="pin-input"
@keydown.enter="unlock()"
/>
<div x-show="pinError" class="pin-error" x-text="pinError"></div>
<button @click="unlock()" class="btn-primary" :disabled="unlocking">
<span x-show="!unlocking">Unlock</span>
<span x-show="unlocking">Unlocking…</span>
</button>
</div>
</div>
<!-- ── Normal wallet UI (hidden during setup/seed screens) ──────────── -->
<template x-if="tab !== 'setup' && !seed.screen">
<div class="wallet-ui">
<!-- Header -->
<header class="header">
<span class="header-title" x-text="botName"></span>
<div class="lock-badge" :class="wallet.locked ? 'locked' : 'unlocked'">
<span x-text="wallet.locked ? '🔒 Locked' : '🔓 Unlocked'"></span>
</div>
</header>
<!-- Tab content -->
<main class="content">
<!-- Dashboard -->
<section x-show="tab === 'dashboard'" x-transition>
<div class="balance-card">
<div class="balance-label">Balance</div>
<div class="balance-amount">
<span x-text="wallet.locked ? '—' : wallet.balanceSats.toLocaleString()"></span>
<span class="balance-unit">sats</span>
</div>
<div class="balance-btc" x-show="!wallet.locked">
<span x-text="satsToBtc(wallet.balanceSats)"></span> BTC
</div>
<div x-show="wallet.locked" class="unlock-prompt">
<button @click="showPinOverlay = true" class="btn-primary">Unlock to see balance</button>
</div>
</div>
<div class="quick-actions">
<button @click="tab = 'send'" class="quick-btn">
<span class="quick-icon"></span><span>Send</span>
</button>
<button @click="tab = 'receive'; loadReceive()" class="quick-btn">
<span class="quick-icon"></span><span>Receive</span>
</button>
<button @click="tab = 'history'; loadHistory()" class="quick-btn">
<span class="quick-icon">🕐</span><span>History</span>
</button>
</div>
<div x-show="!wallet.locked" class="address-row">
<div class="address-label">Spark address</div>
<div class="address-value" x-text="wallet.sparkAddress" @click="copyText(wallet.sparkAddress)"></div>
</div>
<div x-show="!wallet.locked" class="session-info">
<button @click="lock()" class="btn-secondary btn-sm">Lock wallet</button>
<span class="hint" x-show="wallet.unlockExpiresAt">
Expires <span x-text="formatExpiry(wallet.unlockExpiresAt)"></span>
</span>
</div>
</section>
<!-- Send -->
<section x-show="tab === 'send'" x-transition>
<h2 class="section-title">Send</h2>
<div x-show="wallet.locked" class="lock-notice">
<p>Wallet is locked.</p>
<button @click="showPinOverlay = true" class="btn-primary">Unlock</button>
</div>
<template x-if="!wallet.locked">
<div>
<label class="field-label">Destination</label>
<textarea
x-model="send.destination"
placeholder="Lightning invoice / Spark address / Bitcoin address"
class="field-textarea"
rows="3"
@input="detectType()"
></textarea>
<div x-show="send.detectedType" class="type-badge" x-text="send.detectedType"></div>
<template x-if="send.detectedType !== '⚡ Lightning'">
<div>
<label class="field-label">Amount (sats)</label>
<input x-model="send.amountSats" type="number" placeholder="0" class="field-input" min="1" />
</div>
</template>
<label class="field-label">Note (optional)</label>
<input x-model="send.description" type="text" placeholder="For coffee…" class="field-input" />
<div x-show="send.error" class="error-msg" x-text="send.error"></div>
<div x-show="send.success" class="success-msg" x-text="send.success"></div>
<button @click="sendPayment()" class="btn-primary btn-wide" :disabled="send.sending || !send.destination">
<span x-show="!send.sending">Send ⚡</span>
<span x-show="send.sending">Sending…</span>
</button>
</div>
</template>
</section>
<!-- Receive -->
<section x-show="tab === 'receive'" x-transition>
<h2 class="section-title">Receive</h2>
<div class="receive-tabs">
<template x-for="t in ['spark', 'lightning', 'onchain']" :key="t">
<button
@click="receive.tab = t; loadReceive()"
class="receive-tab"
:class="{ active: receive.tab === t }"
x-text="t.charAt(0).toUpperCase() + t.slice(1)"
></button>
</template>
</div>
<div x-show="receive.tab === 'spark'">
<div class="qr-wrap"><canvas id="qr-spark"></canvas></div>
<div class="address-copy" x-text="receive.sparkAddress" @click="copyText(receive.sparkAddress)"></div>
<p class="hint">Tap to copy. Works for Spark-to-Spark payments.</p>
</div>
<div x-show="receive.tab === 'lightning'">
<div x-show="wallet.locked" class="lock-notice">
<button @click="showPinOverlay = true" class="btn-primary">Unlock to generate invoice</button>
</div>
<template x-if="!wallet.locked">
<div>
<label class="field-label">Amount (sats, leave blank for any)</label>
<input x-model="receive.amountSats" type="number" placeholder="0" class="field-input" min="1" />
<button @click="loadReceive()" class="btn-secondary" style="margin-top:8px">Generate Invoice</button>
<template x-if="receive.lightningInvoice">
<div>
<div class="qr-wrap"><canvas id="qr-lightning"></canvas></div>
<div class="address-copy invoice-text" x-text="receive.lightningInvoice" @click="copyText(receive.lightningInvoice)"></div>
</div>
</template>
</div>
</template>
</div>
<div x-show="receive.tab === 'onchain'">
<div x-show="wallet.locked" class="lock-notice">
<button @click="showPinOverlay = true" class="btn-primary">Unlock to get address</button>
</div>
<template x-if="!wallet.locked && receive.onchainAddress">
<div>
<div class="qr-wrap"><canvas id="qr-onchain"></canvas></div>
<div class="address-copy" x-text="receive.onchainAddress" @click="copyText(receive.onchainAddress)"></div>
<p class="hint">On-chain deposits may take 16 confirmations.</p>
</div>
</template>
</div>
</section>
<!-- History -->
<section x-show="tab === 'history'" x-transition>
<h2 class="section-title">History</h2>
<div x-show="history.loading" class="hint">Loading…</div>
<div x-show="!history.loading && history.items.length === 0" class="hint">No transactions yet.</div>
<ul class="tx-list">
<template x-for="tx in history.items" :key="tx.id">
<li class="tx-item">
<div class="tx-left">
<span class="tx-icon" x-text="txIcon(tx.status)"></span>
<div>
<div class="tx-amount" x-text="tx.amount_sats.toLocaleString() + ' sats'"></div>
<div class="tx-dest" x-text="tx.recipient_address ? tx.recipient_address.slice(0, 30) + '…' : 'unknown'"></div>
</div>
</div>
<div class="tx-right">
<div class="tx-status" :class="'status-' + tx.status" x-text="tx.status"></div>
<div class="tx-date" x-text="formatDate(tx.created_at)"></div>
</div>
</li>
</template>
</ul>
<button x-show="history.items.length >= history.limit" @click="loadMore()" class="btn-secondary btn-wide">Load more</button>
</section>
</main>
<!-- Bottom tab bar -->
<nav class="tab-bar">
<button @click="tab = 'dashboard'; refresh()" class="tab-btn" :class="{ active: tab === 'dashboard' }">
<span class="tab-icon">💼</span><span class="tab-label">Wallet</span>
</button>
<button @click="tab = 'send'" class="tab-btn" :class="{ active: tab === 'send' }">
<span class="tab-icon"></span><span class="tab-label">Send</span>
</button>
<button @click="tab = 'receive'; loadReceive()" class="tab-btn" :class="{ active: tab === 'receive' }">
<span class="tab-icon"></span><span class="tab-label">Receive</span>
</button>
<button @click="tab = 'history'; loadHistory()" class="tab-btn" :class="{ active: tab === 'history' }">
<span class="tab-icon">🕐</span><span class="tab-label">History</span>
</button>
</nav>
</div>
</template>
<div class="toast" x-show="toastVisible" x-transition>Copied!</div>
<script src="app.js"></script>
</body>
</html>