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>
334 lines
14 KiB
HTML
334 lines
14 KiB
HTML
<!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 1–6 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>
|