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:
goyban
2026-05-03 13:21:43 +00:00
commit e857bf4ec6
40 changed files with 4689 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
<!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>