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
+28
View File
@@ -0,0 +1,28 @@
# ── Telegram ──────────────────────────────────────────────────────────────────
BOT_TOKEN=
# ── Breez SDK ─────────────────────────────────────────────────────────────────
BREEZ_API_KEY=
# ── Database ──────────────────────────────────────────────────────────────────
# SQLite (default)
DATABASE_URL=sqlite:./data/bot.db
# Postgres alternative:
# DATABASE_URL=postgresql://user:pass@localhost:5432/botdb
# ── Federated registry (optional) ────────────────────────────────────────────
REGISTRY_URL=
REGISTRY_WRITE_KEY=
REGISTRY_READ_KEY=
# ── Session ───────────────────────────────────────────────────────────────────
SESSION_SWEEP_INTERVAL_MS=60000
# ── Bot identity ──────────────────────────────────────────────────────────────
BOT_INSTANCE_NAME=gbnbot
# ── Mini App ──────────────────────────────────────────────────────────────────
# WEBAPP_URL must be a public HTTPS URL (Telegram requirement for Mini Apps).
# Point your reverse proxy (nginx/Caddy) to WEBAPP_PORT.
WEBAPP_PORT=8458
WEBAPP_URL=https://yourbot.example.com
+22
View File
@@ -0,0 +1,22 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment — never commit real credentials
.env
# Database
data/
*.db
*.db-shm
*.db-wal
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
+459
View File
@@ -0,0 +1,459 @@
# CLAUDE.md — Federated Self-Custodial Spark Tip Bot
## Project overview
A self-hosted Telegram tip bot that sends satoshis over the Spark L2 / Lightning network.
Designed for small trusted circles (friends, family). Multiple independent bot instances
can share a federated user registry to form a web of trust. All funds are self-custodial —
the bot operator can never access user funds. If the bot goes offline, users recover their
funds using a standard BIP-39 seed phrase in any compatible wallet.
---
## Core principles
- **Self-custodial**: each user's mnemonic is encrypted with their own PIN/passphrase.
The server stores only the encrypted blob. Funds are never accessible to the bot operator.
- **Non-federated fallback**: if the bot goes offline permanently, users import their
BIP-39 seed into Breez, Phoenix, or any Spark-compatible wallet and keep their funds.
- **Federated by choice**: bot instances opt in to sharing a public registry of
`Telegram ID → Spark address` mappings. No funds are stored in the registry.
- **Identity portability**: users can claim multiple Telegram identities (alt accounts,
channels, group admin personas) and route all incoming payments to one wallet.
- **Container Based**: Everything should be done on docker containers, a good docker compose is what we need.
---
## Tech stack
| Layer | Choice | Reason |
|---|---|---|
| Bot framework | **grammY** (TypeScript) | Best inline query support, actively maintained, tracks latest Bot API |
| Payment SDK | **`@breeztech/breez-sdk-spark`** | Native TS, handles LNURL + Lightning addresses + Spark natively |
| Database | **SQLite** (small) or **Postgres** (if web UI wanted) | Local per-instance + shared registry |
| Runtime | **Node.js 22+** | Required by Breez SDK |
| Deployment | **Docker Compose** | Easy self-hosting, one compose file per instance |
| Session store | **In-memory Map with TTL** | Decrypted mnemonics never re-persisted to disk |
---
## Repository structure
```
/
├── src/
│ ├── bot/
│ │ ├── index.ts # grammY bot entry point
│ │ ├── commands/
│ │ │ ├── tip.ts # /tip command handler
│ │ │ ├── unlock.ts # /unlock, /lock commands
│ │ │ ├── register.ts # /start onboarding
│ │ │ ├── contact.ts # /contact add|list|remove
│ │ │ ├── claim.ts # /claim identity flow
│ │ │ └── settings.ts # /settings command
│ │ └── inline/
│ │ └── tip.ts # @mybot tip 100 [alias] inline handler
│ ├── payments/
│ │ ├── wallet.ts # Breez SDK wrapper per user
│ │ ├── session.ts # In-memory unlock session manager
│ │ └── registry.ts # Federated registry client
│ ├── db/
│ │ ├── schema.ts # DB schema definitions
│ │ ├── users.ts # User repo
│ │ ├── claims.ts # Identity claims repo
│ │ ├── contacts.ts # Alias/contacts repo
│ │ └── pending.ts # Pending transaction queue
│ └── config.ts # Env var config
├── docker-compose.yml
├── .env.example
└── CLAUDE.md # This file
```
---
## Database schema
### `users`
```sql
id BIGINT PRIMARY KEY -- Telegram User ID
username TEXT -- current @username (informational only)
encrypted_mnemonic TEXT NOT NULL -- AES-256-GCM encrypted BIP-39 mnemonic
spark_address TEXT -- user's Lightning/Spark address
unlock_duration INTEGER DEFAULT 3600 -- preferred session TTL in seconds
onboarded_at TIMESTAMP
```
### `identity_claims`
```sql
id SERIAL PRIMARY KEY
claimed_id TEXT NOT NULL -- @username, channel ID, group ID, etc.
claimed_id_type TEXT NOT NULL -- 'username' | 'channel' | 'group_admin'
owned_by_user_id BIGINT NOT NULL REFERENCES users(id)
allow_spending BOOLEAN DEFAULT TRUE -- can this identity trigger outgoing payments?
challenge_code TEXT -- null after verification
challenge_expiry TIMESTAMP
verified_at TIMESTAMP
```
### `contacts` (local aliases)
```sql
id SERIAL PRIMARY KEY
owner_user_id BIGINT NOT NULL REFERENCES users(id)
alias TEXT NOT NULL -- e.g. "satoshi"
target_user_id BIGINT -- resolved Telegram User ID
target_address TEXT -- fallback: direct Lightning address
```
### `pending_transactions`
```sql
id UUID PRIMARY KEY
initiator_user_id BIGINT NOT NULL -- who triggered /tip
recipient_user_id BIGINT -- resolved recipient (may be null if unresolved)
recipient_address TEXT -- resolved Spark/Lightning address
amount_sats INTEGER NOT NULL
status TEXT DEFAULT 'awaiting_unlock' -- awaiting_unlock | processing | done | expired | failed
initiated_via TEXT -- 'command' | 'inline'
group_chat_id BIGINT -- for posting confirmation back
created_at TIMESTAMP
expires_at TIMESTAMP -- default: now + 2 minutes
```
### `federated_registry` (shared across instances, or remote)
```sql
telegram_id BIGINT PRIMARY KEY -- Telegram User ID
spark_address TEXT NOT NULL
published_by TEXT -- which bot instance published this
updated_at TIMESTAMP
```
---
## Payment flow: bot in group (`/tip` command)
```
User replies to a message and sends: /tip 100
1. Bot reads reply_to_message.from.id → recipient Telegram ID
2. Bot resolves recipient address:
a. Check local users table
b. Check identity_claims (channel/group/alt account mappings)
c. Check federated registry
d. If not found → reply "recipient not registered, send them this link: t.me/mybot"
3. Check sender session:
a. Session unlocked → proceed immediately
b. Session locked → save pending_transaction, DM sender:
"Unlock your wallet to send 100 sats to @recipient. /unlock"
4. On unlock → process pending transaction → pay via Breez SDK
5. Bot confirms in group: "⚡ 100 sats sent to @recipient"
```
**Requirement:** Bot must be added to the group. Privacy mode OFF, or bot is admin.
---
## Payment flow: inline mode (`@mybot tip 100 [alias]`)
```
User types: @mybot tip 100 satoshi (bot NOT in group)
1. Bot receives InlineQuery with text "tip 100 satoshi"
2. Bot resolves "satoshi":
a. Check sender's contacts/aliases table → finds User ID + Spark address
b. If not found → return inline result "Unknown alias. Add with /contact add"
3. Bot returns inline result card: "⚡ Send 100 sats to Satoshi — tap to send"
4. User taps result → message posted in group
5. Bot receives ChosenInlineResult → processes payment (session rules apply as above)
6. Bot edits the message or DMs sender: "✅ Sent"
```
**Note:** Inline mode cannot detect reply context. Recipient must be specified by alias,
username, or ID in the query text. The alias system is the primary UX for this mode.
---
## Payment flow: inline mode without alias (claim button fallback)
```
User types: @mybot tip 100 (no alias, bot not in group)
1. Bot returns inline result: "⚡ Send 100 sats — tap to post, recipient claims it"
2. Message posted in group with [Claim] inline button
3. Recipient taps [Claim] → CallbackQuery carries their User ID
4. Bot resolves their address → pays
5. Bot updates message: "✅ 100 sats claimed by @recipient"
```
---
## Session unlock system
Inspired by `sudo` — authenticate once, act many times within a window.
### Commands
```
/unlock → bot asks for PIN in DM, uses stored unlock_duration preference
/unlock 4h → unlock for 4 hours regardless of preference
/unlock 1tx → unlock for one transaction only, then auto-lock
/lock → immediately wipe session
/settings unlock_duration 24h → set default unlock window
```
### Session state (in-memory only, never persisted)
```typescript
interface UnlockSession {
userId: number
decryptedMnemonic: string // wiped on expiry or /lock
unlockedAt: Date
expiresAt: Date
policy: 'timed' | 'tx-only'
txRemaining?: number // for tx-only policy
}
```
### Security rules
- Decrypted mnemonic lives **only in a Map<userId, UnlockSession>** in process memory
- A background job runs every 60 seconds, evicts expired sessions, wipes mnemonic string
- On process restart (crash, update, reboot): all sessions wiped automatically
- Bot notifies users on restart: *"Bot restarted — session cleared. /unlock to continue."*
- PIN is only ever entered in a **private DM with the bot**, never in a group
---
## Identity claim system
Users can claim multiple Telegram identities and route all incoming payments to one wallet.
The personal account must be onboarded first. Claims are extensions of a personal identity.
### Claimable identity types
#### 1. Regular username / alternate account
```
/claim @otherusername
→ Bot generates challenge: "spark-verify-a7f3k9"
→ User sends that exact text from @otherusername to the bot in DM
→ Bot verifies sender ID matches claimed username
→ Claim stored, verified
```
#### 2. Channel
```
/claim @mychannel
→ Bot generates challenge: "spark-verify-x2m8p1"
→ User posts challenge in @mychannel (as themselves or as channel)
→ User forwards that channel post to the bot
→ Bot reads forward_origin.chat.id → matches @mychannel
→ Bot reads forward_origin message text → matches challenge code
→ Claim stored, verified. User may delete the post.
```
#### 3. Anonymous group admin / group owner
```
/claim @mygroup
→ Bot generates challenge: "spark-verify-q9w2n4"
→ User sends challenge IN the group, posted AS the group identity
(Telegram: switch to "send as group/channel" mode)
→ User forwards that message to the bot
→ Bot reads forward_origin → sender is the group identity
→ Bot verifies challenge code
→ Claim stored, verified
```
For the group owner case specifically:
- Only the group owner can post "as the group"
- Forwarding a message sent as the group proves ownership
- No need to add the bot to the group permanently
### Challenge rules
- Codes are cryptographically random, min 12 characters
- Challenges expire after 10 minutes
- After verification, challenge code is deleted from DB
- A new valid claim for the same identity supersedes the old one (re-claim = transfer)
### Spending from a claimed identity
When a user posts `/tip 100` **as their channel** in a group:
```
Bot sees: message from channel identity @mychannel
→ Looks up identity_claims: @mychannel owned by User ID 111111
→ Checks allow_spending = true for this claim
→ Checks if User ID 111111's personal session is unlocked
→ Yes: send from their wallet
→ No: DM User ID 111111 (personal account):
"@mychannel tried to tip 100 sats — unlock to confirm"
```
**Constraint:** The personal account must have a DM channel open with the bot (must have
started the bot at least once). Channel/group identities cannot receive bot DMs directly.
### Identity resolution chain (lookup order)
When resolving a payment target:
```
1. contacts table (sender's local aliases) — highest priority
2. users table (direct Telegram User ID match)
3. identity_claims table (channel ID, group ID, alt username → owner)
4. federated registry (other bot instances' published addresses)
5. Not found → prompt to register
```
---
## Federated registry
### What it stores
Only public routing information. No funds, no mnemonics, no private data.
```
Telegram User ID → Spark/Lightning address
```
### How it works
- Each bot instance has a **write API key** for the registry
- On user registration or address change, the bot publishes to the registry
- Registry entries are **opt-in** — users can disable publishing in /settings
- Any bot instance can **read** the registry (read is public or requires a shared read key)
- Registry can be a shared Postgres instance, a simple REST API, or a Nostr relay
### Nostr relay option (recommended for decentralization)
- Each user's `Telegram ID → address` mapping published as a Nostr event
- Kind 0 or a custom kind, signed by the bot instance's Nostr key
- Any other instance subscribes to the relay and caches entries locally
- No central server required — censorship resistant
### Trust model
- The registry does not verify claims — it only routes payments
- If a malicious registry entry exists, the worst case is a payment goes to the wrong address
- Mitigated by: local DB takes priority over registry, users can verify their own entries
---
## Escape hatch / recovery
If the bot goes offline permanently:
1. User requests their encrypted mnemonic export via `/export` before shutdown
(or bot operator exports DB and notifies users)
2. User decrypts with their PIN
3. User imports the 12-word BIP-39 seed into:
- Breez wallet
- Phoenix wallet
- Any Spark-compatible wallet
4. Funds are fully accessible, no dependency on the bot
This must be documented clearly in the bot's onboarding `/start` message.
---
## Bot commands reference
### Onboarding
```
/start → creates wallet, explains PIN setup, shows recovery seed warning
/export → exports encrypted mnemonic (user decrypts offline with their PIN)
```
### Payments
```
/tip 100 [@user|alias] → send sats (in group, as reply or with target)
@mybot tip 100 [alias] → inline tip (anywhere, alias required for direct pay)
/balance → check wallet balance
/history → recent transactions
```
### Session
```
/unlock [duration] → unlock wallet (PIN entered in DM)
/lock → immediately lock wallet
```
### Contacts / aliases
```
/contact add <alias> <@username|userid> → save local alias
/contact list → show all aliases
/contact remove <alias> → delete alias
```
### Identity claims
```
/claim <@username|@channel|@group> → start claim flow
/unclaim <identity> → release a claim
/identities → list all claimed identities
```
### Settings
```
/settings unlock_duration <1h|4h|24h|1tx> → set default unlock window
/settings registry <on|off> → opt in/out of federated registry
/settings spending <@identity> <on|off> → allow/deny spending from claimed identity
```
---
## Security considerations
- **PIN never enters a group chat** — always collected in private DM with bot
- **Mnemonic never written to disk unencrypted** — only the AES-256-GCM blob is persisted
- **Session wipe on restart** — process crash = all sessions cleared, users re-unlock
- **Claim expiry** — challenge codes expire in 10 minutes, deleted after use
- **Claim supersession** — new valid proof overwrites old claim (supports ownership transfer)
- **Registry read-only to peers** — other instances cannot write your users' addresses
- **allow_spending flag** — claimed identities default to allow spending, user can restrict
- **Pending tx expiry** — unclaimed pending transactions expire after 2 minutes
---
## Environment variables
```env
# Telegram
BOT_TOKEN=
# Breez SDK
BREEZ_API_KEY=
# Database
DATABASE_URL=sqlite:./data/bot.db
# or: postgresql://user:pass@host:5432/botdb
# Federated registry (optional)
REGISTRY_URL=https://registry.yourdomain.com
REGISTRY_WRITE_KEY=
REGISTRY_READ_KEY=
# Session
SESSION_SWEEP_INTERVAL_MS=60000 # how often to evict expired sessions
# Bot identity
BOT_INSTANCE_NAME=mybot # shown in registry entries
```
---
## Key implementation notes
1. **One Breez SDK instance per active user session** — connect on unlock, disconnect on lock
or expiry. Do not keep SDK instances open indefinitely.
2. **grammY conversation plugin** — use it for multi-step flows (claim verification, PIN
entry, onboarding). Keeps state cleanly without manual FSM.
3. **grammY bot.on("inline_query")** — parse query text manually: `tip <amount> [alias]`.
Return results immediately (Telegram requires response within a few seconds).
4. **forward_origin field** — available in grammY as `ctx.message.forward_origin`. Check
`type === 'channel'` and read `.chat.id` for channel claim verification.
5. **Privacy mode** — if bot is in a group with privacy mode ON, it only sees messages
that start with `/`. Turn OFF for `/tip` to work as a reply in groups, or make bot admin.
6. **Telegram User ID is stable** — never use username as a primary key. Usernames can
change. Always store and resolve by numeric User ID.
7. **Breez SDK mnemonic per user** — each user gets their own mnemonic, not account
derivation from a master seed. Simpler recovery, full portability.
8. **Process restart notification** — on startup, check for users with previously unlocked
sessions (session state is in-memory, so this is everyone) and send DM:
"Bot restarted — your session was cleared. /unlock to continue."
Requires storing a `last_unlock_notified_at` flag or simply notifying on first
interaction after restart.
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache python3 make g++ sqlite
COPY package.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
COPY webapp ./webapp
RUN mkdir -p /app/data && chown -R node:node /app/data
USER node
CMD ["node", "dist/bot/index.js"]
+28
View File
@@ -0,0 +1,28 @@
services:
bot:
build: .
restart: unless-stopped
environment:
BOT_TOKEN: ${BOT_TOKEN}
BREEZ_API_KEY: ${BREEZ_API_KEY}
DATABASE_URL: sqlite:./data/bot.db
REGISTRY_URL: ${REGISTRY_URL:-}
REGISTRY_WRITE_KEY: ${REGISTRY_WRITE_KEY:-}
REGISTRY_READ_KEY: ${REGISTRY_READ_KEY:-}
SESSION_SWEEP_INTERVAL_MS: ${SESSION_SWEEP_INTERVAL_MS:-60000}
BOT_INSTANCE_NAME: ${BOT_INSTANCE_NAME:-mybot}
WEBAPP_PORT: ${WEBAPP_PORT:-3000}
WEBAPP_URL: ${WEBAPP_URL:-}
NODE_ENV: production
ports:
- "${WEBAPP_PORT:-3000}:${WEBAPP_PORT:-3000}"
volumes:
- bot_data:/app/data
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:${WEBAPP_PORT:-3000}/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
volumes:
bot_data:
+32
View File
@@ -0,0 +1,32 @@
{
"name": "tip-ln-bot",
"version": "1.0.0",
"description": "Federated self-custodial Spark/Lightning Telegram tip bot",
"main": "dist/bot/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/bot/index.js",
"dev": "tsx watch src/bot/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"grammy": "^1.30.0",
"@grammyjs/conversations": "^1.0.0",
"@grammyjs/runner": "^2.0.3",
"better-sqlite3": "^11.9.1",
"bip39": "^3.1.0",
"fastify": "^5.3.2",
"@fastify/cors": "^10.0.2",
"@fastify/static": "^8.1.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/bip39": "^3.0.4",
"@types/node": "^22.0.0",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22.0.0"
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Telegram Mini App initData validation.
*
* Spec: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
*
* Algorithm:
* secretKey = HMAC_SHA256("WebAppData", botToken)
* checkString = sorted key=value pairs (excluding hash) joined by \n
* signature = HMAC_SHA256(checkString, secretKey)
* valid if signature === hash field from initData
*/
import { createHmac, timingSafeEqual } from "crypto";
import { config } from "../config";
export interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
}
export interface ValidatedInitData {
user: TelegramUser;
auth_date: number;
query_id?: string;
}
const MAX_AGE_SECONDS = 86_400; // 24 hours
export function validateInitData(rawInitData: string): ValidatedInitData {
const params = new URLSearchParams(rawInitData);
const hash = params.get("hash");
if (!hash) throw new AuthError("Missing hash in initData");
// Validate hash is exactly 64 lowercase hex chars before byte comparison.
// timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH on mismatched
// buffer sizes, which would surface as an unhandled 500 instead of a clean 401.
if (!/^[0-9a-f]{64}$/.test(hash)) throw new AuthError("Malformed hash in initData");
// Build the data-check string
params.delete("hash");
const checkString = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join("\n");
const secretKey = createHmac("sha256", "WebAppData")
.update(config.botToken)
.digest();
const expectedHash = createHmac("sha256", secretKey)
.update(checkString)
.digest("hex");
if (!timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex"))) {
throw new AuthError("Invalid initData signature");
}
// Reject stale tokens
const authDate = Number(params.get("auth_date") ?? 0);
if (Date.now() / 1000 - authDate > MAX_AGE_SECONDS) {
throw new AuthError("initData expired");
}
const userJson = params.get("user");
if (!userJson) throw new AuthError("Missing user in initData");
let user: TelegramUser;
try {
user = JSON.parse(userJson) as TelegramUser;
} catch {
throw new AuthError("Malformed user field in initData");
}
return {
user,
auth_date: authDate,
query_id: params.get("query_id") ?? undefined,
};
}
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthError";
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Single-use in-memory token store for mnemonic reveal.
*
* A token is created right after wallet generation, lives for TOKEN_TTL_MS,
* and is destroyed the moment it is read — whichever comes first.
* The mnemonic string is zero-filled on destruction.
*
* Two consumption paths share the same entry:
* - consumeRevealToken(token, userId) → Mini App path (token from URL)
* - consumeRevealByUserId(userId) → "show in chat" callback path
* Whichever fires first wipes the entry so the other gets null.
*/
import { randomBytes } from "crypto";
const TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface Entry {
userId: number;
mnemonic: string;
timer: ReturnType<typeof setTimeout>;
}
const byToken = new Map<string, Entry>();
const byUserId = new Map<number, string>(); // userId → token (reverse index)
export function createRevealToken(userId: number, mnemonic: string): string {
// Wipe any previous pending entry for this user
const prev = byUserId.get(userId);
if (prev) wipeToken(prev);
const token = randomBytes(32).toString("hex");
const timer = setTimeout(() => wipeToken(token), TOKEN_TTL_MS);
timer.unref?.();
byToken.set(token, { userId, mnemonic, timer });
byUserId.set(userId, token);
return token;
}
/** Mini App path: consume by token string embedded in the URL. */
export function consumeRevealToken(token: string, userId: number): string | null {
const entry = byToken.get(token);
if (!entry || entry.userId !== userId) return null;
const mnemonic = entry.mnemonic;
wipeToken(token);
return mnemonic;
}
/** "Show in chat" path: consume by userId (no token needed). */
export function consumeRevealByUserId(userId: number): string | null {
const token = byUserId.get(userId);
if (!token) return null;
const entry = byToken.get(token);
if (!entry) return null;
const mnemonic = entry.mnemonic;
wipeToken(token);
return mnemonic;
}
function wipeToken(token: string): void {
const entry = byToken.get(token);
if (!entry) return;
clearTimeout(entry.timer);
byUserId.delete(entry.userId);
(entry as { mnemonic: string }).mnemonic = "\0".repeat(entry.mnemonic.length);
byToken.delete(token);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Simple in-memory rate limiter for PIN attempts.
*
* After MAX_FAILURES consecutive wrong PINs the account is locked for
* LOCKOUT_MS. The counter resets on a successful unlock.
*/
const MAX_FAILURES = 5;
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
interface Entry {
failures: number;
lockedUntil: number | null;
}
const store = new Map<number, Entry>();
function entry(userId: number): Entry {
if (!store.has(userId)) store.set(userId, { failures: 0, lockedUntil: null });
return store.get(userId)!;
}
export function checkPinRateLimit(userId: number): void {
const e = entry(userId);
if (e.lockedUntil && Date.now() < e.lockedUntil) {
const secondsLeft = Math.ceil((e.lockedUntil - Date.now()) / 1000);
throw new RateLimitError(`Too many incorrect PINs. Try again in ${secondsLeft}s.`);
}
}
export function recordPinFailure(userId: number): void {
const e = entry(userId);
e.failures += 1;
if (e.failures >= MAX_FAILURES) {
e.lockedUntil = Date.now() + LOCKOUT_MS;
e.failures = 0;
}
}
export function recordPinSuccess(userId: number): void {
store.delete(userId); // reset counter on success
}
export class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "RateLimitError";
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { findUser } from "../../db/users";
import { getDb } from "../../db/schema";
interface TxRow {
id: string;
amount_sats: number;
status: string;
recipient_address: string | null;
recipient_user_id: number | null;
initiated_via: string;
created_at: string;
}
export async function historyRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Querystring: { limit?: string; offset?: string } }>(
"/api/history",
async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
if (!findUser(userId)) return reply.status(404).send({ error: "Wallet not found" });
const limit = Math.min(parseInt(req.query.limit ?? "20", 10), 100);
const offset = parseInt(req.query.offset ?? "0", 10);
const rows = getDb()
.prepare(
`SELECT id, amount_sats, status, recipient_address, recipient_user_id,
initiated_via, created_at
FROM pending_transactions
WHERE initiator_user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`
)
.all(userId, limit, offset) as TxRow[];
return { transactions: rows, limit, offset };
}
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { findUser } from "../../db/users";
import { getSession } from "../../payments/session";
import { getReceiveAddresses } from "../../payments/wallet";
interface ReceiveQuery {
amount?: string;
description?: string;
}
export async function receiveRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Querystring: ReceiveQuery }>("/api/receive", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
const user = findUser(userId);
if (!user) return reply.status(404).send({ error: "Wallet not found" });
const session = getSession(userId);
// Spark address is always available (stored at registration)
const sparkAddress = user.spark_address ?? "";
// Lightning invoice and on-chain address require an unlocked session
if (!session) {
return {
locked: true,
sparkAddress,
lightningInvoice: null,
onchainAddress: null,
message: "Unlock your wallet to generate Lightning invoices and on-chain addresses.",
};
}
const amountSats = req.query.amount ? parseInt(req.query.amount, 10) : undefined;
const description = req.query.description;
const addresses = await getReceiveAddresses(userId, amountSats, description);
return {
locked: false,
sparkAddress: addresses.sparkAddress,
lightningInvoice: addresses.lightningInvoice,
onchainAddress: addresses.onchainAddress,
};
});
}
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { consumeRevealToken } from "../onetime";
export async function seedRoutes(app: FastifyInstance): Promise<void> {
/**
* GET /api/seed/reveal?token=<hex>
*
* Returns the 12-word mnemonic exactly once, then the token is wiped.
* Requires a valid Telegram initData header so only the wallet owner
* can consume the token — even if someone else obtains the URL.
*/
app.get<{ Querystring: { token?: string } }>("/api/seed/reveal", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
const token = req.query.token;
if (!token || typeof token !== "string") {
return reply.status(400).send({ error: "token is required" });
}
const mnemonic = consumeRevealToken(token, userId);
if (!mnemonic) {
return reply.status(410).send({
error: "Token expired or already used. This link can only be opened once.",
});
}
// Split into word array so the Mini App can render them in a grid
return { words: mnemonic.split(" ") };
});
}
+86
View File
@@ -0,0 +1,86 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { findUser } from "../../db/users";
import { getSession } from "../../payments/session";
import { sendPayment, detectPaymentType } from "../../payments/wallet";
import { consumeTx } from "../../payments/session";
import { createPending, updateStatus } from "../../db/pending";
interface SendBody {
destination: string; // BOLT11 invoice | Spark address | Bitcoin address
amountSats?: number; // required for Spark/on-chain; embedded in BOLT11
description?: string;
}
export async function sendRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: SendBody }>("/api/send", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
if (!findUser(userId)) return reply.status(404).send({ error: "Wallet not found" });
const { destination, amountSats, description } = req.body ?? {};
if (!destination || typeof destination !== "string") {
return reply.status(400).send({ error: "destination is required" });
}
const paymentType = await detectPaymentType(destination);
const MAX_SATS = 21_000_000 * 100_000_000;
if (amountSats !== undefined && (amountSats <= 0 || amountSats > MAX_SATS || !Number.isInteger(amountSats))) {
return reply.status(400).send({ error: "amountSats must be a positive integer ≤ 21M BTC in sats" });
}
if ((paymentType === "spark" || paymentType === "onchain") && !amountSats) {
return reply.status(400).send({ error: "amountSats is required for Spark and on-chain payments" });
}
const session = getSession(userId);
if (!session) {
return reply.status(403).send({
error: "wallet_locked",
message: "Unlock your wallet first.",
});
}
const sats = amountSats ?? 0;
// Record the transaction for history
const txId = createPending({
initiatorUserId: userId,
recipientUserId: null,
recipientAddress: destination,
amountSats: sats,
initiatedVia: "command", // mini app sends are treated same as commands
groupChatId: null,
});
updateStatus(txId, "processing");
try {
const result = await sendPayment(userId, destination, sats);
consumeTx(userId);
updateStatus(txId, "done");
return {
success: true,
txId: result.txId,
feeSats: result.feeSats,
paymentType,
};
} catch (err) {
updateStatus(txId, "failed");
const message = err instanceof Error ? err.message : "Payment failed";
return reply.status(500).send({ error: message });
}
});
}
+73
View File
@@ -0,0 +1,73 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../ratelimit";
import { findUser, createUser } from "../../db/users";
import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet";
import { encryptMnemonic } from "../../payments/crypto";
import { publishToRegistry } from "../../payments/registry";
const MIN_PIN_LENGTH = 6;
interface SetupBody {
pin: string;
}
export async function setupRoutes(app: FastifyInstance): Promise<void> {
/**
* POST /api/setup
*
* Creates a new wallet for the authenticated Telegram user.
* Returns the seed words once in the response — the Mini App displays and
* discards them. Nothing is stored in Telegram chat history.
*/
app.post<{ Body: SetupBody }>("/api/setup", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
let username: string | undefined;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
username = validated.user.username;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
if (findUser(userId)) {
return reply.status(409).send({ error: "Wallet already exists." });
}
const { pin } = req.body ?? {};
if (!pin || typeof pin !== "string") {
return reply.status(400).send({ error: "pin is required" });
}
if (pin.length < MIN_PIN_LENGTH) {
return reply.status(400).send({ error: `PIN must be at least ${MIN_PIN_LENGTH} characters.` });
}
try {
checkPinRateLimit(userId);
} catch (err) {
if (err instanceof RateLimitError) return reply.status(429).send({ error: err.message });
throw err;
}
// Generate and encrypt — PIN is never stored, only the encrypted blob
const mnemonic = generateMnemonic();
const sparkAddress = await deriveSparkAddress(mnemonic);
const encryptedMnemonic = encryptMnemonic(mnemonic, pin);
createUser(userId, username ?? null, encryptedMnemonic, sparkAddress);
recordPinSuccess(userId);
await publishToRegistry(userId, sparkAddress).catch(() => null);
// Return seed words directly — this is an authenticated HTTPS call,
// nothing is written to Telegram's servers.
return {
words: mnemonic.split(" "),
sparkAddress,
};
});
}
+134
View File
@@ -0,0 +1,134 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../ratelimit";
import { findUser } from "../../db/users";
import { decryptMnemonic } from "../../payments/crypto";
import { createSession, destroySession, getSession } from "../../payments/session";
import { connectWallet, disconnectWallet } from "../../payments/wallet";
import { findPendingForUser, updateStatus } from "../../db/pending";
import { sendPayment } from "../../payments/wallet";
import { consumeTx } from "../../payments/session";
const MAX_DURATION_SECONDS = 7 * 24 * 3600; // 7-day cap
interface UnlockBody {
pin: string;
durationSeconds?: number; // default: user's stored preference
}
export async function unlockRoutes(app: FastifyInstance): Promise<void> {
// POST /api/unlock — verify PIN, start session, drain pending txs
app.post<{ Body: UnlockBody }>("/api/unlock", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
const user = findUser(userId);
if (!user) return reply.status(404).send({ error: "Wallet not found" });
const { pin, durationSeconds } = req.body ?? {};
if (!pin || typeof pin !== "string") {
return reply.status(400).send({ error: "pin is required" });
}
try {
checkPinRateLimit(userId);
} catch (err) {
if (err instanceof RateLimitError) return reply.status(429).send({ error: err.message });
throw err;
}
let mnemonic: string;
try {
mnemonic = decryptMnemonic(user.encrypted_mnemonic, pin);
recordPinSuccess(userId);
} catch {
recordPinFailure(userId);
return reply.status(401).send({ error: "Incorrect PIN" });
}
const requestedDuration = typeof durationSeconds === "number" ? durationSeconds : null;
const duration = Math.min(
requestedDuration ?? (user.unlock_duration || 3600),
MAX_DURATION_SECONDS
);
await connectWallet(userId, mnemonic);
const session = createSession(userId, mnemonic, "timed", duration);
// Process any queued pending transactions
const pending = findPendingForUser(userId);
const processed: string[] = [];
for (const tx of pending) {
if (!tx.recipient_address) {
updateStatus(tx.id, "failed");
continue;
}
updateStatus(tx.id, "processing");
try {
await sendPayment(userId, tx.recipient_address, tx.amount_sats);
consumeTx(userId);
updateStatus(tx.id, "done");
processed.push(tx.id);
} catch {
updateStatus(tx.id, "failed");
}
}
return {
success: true,
expiresAt: session.expiresAt,
pendingProcessed: processed.length,
};
});
// POST /api/lock
app.post("/api/lock", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
const had = destroySession(userId);
await disconnectWallet(userId);
return { success: true, waslocked: !had };
});
// GET /api/session — check current session state (no PIN needed)
app.get("/api/session", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let userId: number;
try {
const validated = validateInitData(initData);
userId = validated.user.id;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
const session = getSession(userId);
if (!session) return { locked: true };
return {
locked: false,
policy: session.policy,
expiresAt: session.expiresAt,
txRemaining: session.txRemaining ?? null,
};
});
}
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyInstance } from "fastify";
import { validateInitData, AuthError } from "../auth";
import { findUser } from "../../db/users";
import { getSession } from "../../payments/session";
import { getWalletInfo } from "../../payments/wallet";
export async function walletRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/wallet", async (req, reply) => {
const initData = req.headers["x-init-data"] as string | undefined;
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
let tgUser: { id: number };
try {
const validated = validateInitData(initData);
tgUser = validated.user;
} catch (err) {
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
}
const user = findUser(tgUser.id);
if (!user) return { registered: false };
const session = getSession(tgUser.id);
const locked = !session;
let balanceSats = 0;
let sparkAddress = user.spark_address ?? "";
if (!locked) {
const info = await getWalletInfo(tgUser.id);
if (info) {
balanceSats = info.balanceSats;
sparkAddress = info.sparkAddress;
}
}
return {
registered: true,
locked,
balanceSats,
sparkAddress,
unlockExpiresAt: session?.expiresAt ?? null,
sessionPolicy: session?.policy ?? null,
};
});
}
+48
View File
@@ -0,0 +1,48 @@
import Fastify from "fastify";
import fastifyCors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import path from "path";
import { config } from "../config";
import { walletRoutes } from "./routes/wallet";
import { historyRoutes } from "./routes/history";
import { receiveRoutes } from "./routes/receive";
import { sendRoutes } from "./routes/send";
import { unlockRoutes } from "./routes/unlock";
import { seedRoutes } from "./routes/seed";
import { setupRoutes } from "./routes/setup";
export async function startApiServer(): Promise<void> {
const app = Fastify({
logger: config.nodeEnv !== "production",
});
// CORS — Telegram Mini Apps are loaded in an iframe, allow all origins
await app.register(fastifyCors, { origin: true });
// Serve the Mini App static files from /webapp
await app.register(fastifyStatic, {
root: path.join(process.cwd(), "webapp"),
prefix: "/",
});
// Health check (used by Docker)
app.get("/health", async () => ({ ok: true }));
// API routes
await app.register(walletRoutes);
await app.register(historyRoutes);
await app.register(receiveRoutes);
await app.register(sendRoutes);
await app.register(unlockRoutes);
await app.register(seedRoutes);
await app.register(setupRoutes);
// Global error handler
app.setErrorHandler((err: Error & { statusCode?: number }, _req, reply) => {
console.error("[api]", err.message);
reply.status(err.statusCode ?? 500).send({ error: err.message });
});
await app.listen({ port: config.webappPort, host: "0.0.0.0" });
console.log(`[api] Mini App server listening on port ${config.webappPort}`);
}
+185
View File
@@ -0,0 +1,185 @@
import { Composer } from "grammy";
import { type Conversation, createConversation } from "@grammyjs/conversations";
import { randomBytes } from "crypto";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import {
upsertChallenge,
verifyClaim,
deleteClaim,
findVerifiedClaimsForUser,
findClaimByIdentity,
type ClaimedIdType,
} from "../../db/claims";
const CHALLENGE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function generateChallengeCode(): string {
return "spark-verify-" + randomBytes(9).toString("hex"); // 18 hex chars — 72 bits entropy
}
async function claimConversation(
conversation: Conversation<BotContext>,
ctx: BotContext
): Promise<void> {
const userId = ctx.from!.id;
const rawIdentity = (ctx.match as string | undefined)?.trim().replace(/^@/, "");
if (!rawIdentity) {
await ctx.reply("Usage: /claim @username | /claim @channel | /claim @group");
return;
}
if (!findUser(userId)) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
// Prevent overwriting an in-progress challenge by a different user (DoS on claims)
const existing = findClaimByIdentity(rawIdentity);
if (
existing &&
existing.owned_by_user_id !== userId &&
existing.challenge_expiry &&
new Date(existing.challenge_expiry).getTime() > Date.now()
) {
await ctx.reply(
`@${rawIdentity} has an active verification in progress by another user. Try again in a few minutes.`
);
return;
}
const code = generateChallengeCode();
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
await ctx.reply(
`To prove ownership of *@${rawIdentity}*, choose one of:\n\n` +
`*Option A — Alt account / username:*\n` +
`Send the code below *from @${rawIdentity}* in a DM to this bot:\n\`${code}\`\n\n` +
`*Option B — Channel:*\n` +
`Post the code in @${rawIdentity}, then forward that post here.\n\n` +
`*Option C — Group (anonymous admin):*\n` +
`Post the code in the group *as the group identity*, then forward it here.\n\n` +
`Challenge expires in 10 minutes.`,
{ parse_mode: "Markdown" }
);
// Wait for the verification message
const verifyMsg = await conversation.waitFor(["message:text", "message:forward_origin"]);
const msg = verifyMsg.message;
// ── Option A: message sent directly FROM the claimed @username ────────────
// The message must arrive from a DIFFERENT Telegram account whose username
// matches the claimed identity — NOT from the initiating account.
if (msg.text === code) {
const senderUsername = msg.from?.username?.toLowerCase();
const senderUserId = msg.from?.id;
if (senderUserId !== userId && senderUsername === rawIdentity.toLowerCase()) {
// The claimed account sent the code to us directly
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
verifyClaim(rawIdentity);
await ctx.reply(`✅ @${rawIdentity} claimed successfully!`);
return;
}
// Same account sending the code — NOT proof of ownership
await ctx.reply(
`❌ The code must be sent *from @${rawIdentity}*, not from your own account.\n` +
`Try /claim again if the code expired.`,
{ parse_mode: "Markdown" }
);
return;
}
// ── Option B / C: forwarded message from channel or group ─────────────────
type ForwardOrigin = {
type?: string;
chat?: { id?: number; username?: string };
sender_user?: { id?: number };
};
const origin = (msg as { forward_origin?: ForwardOrigin }).forward_origin;
if (!origin) {
await ctx.reply("❌ Could not verify. The message was not sent from @" + rawIdentity + " and was not a forward. Try /claim again.");
return;
}
const forwardedText = (msg as { text?: string }).text?.trim() ?? "";
if (forwardedText !== code) {
await ctx.reply("❌ The forwarded message text does not match the challenge code. Try /claim again.");
return;
}
if (origin.type === "channel") {
const chatUsername = origin.chat?.username?.toLowerCase();
const chatId = String(origin.chat?.id ?? "");
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
upsertChallenge(rawIdentity, "channel", userId, code, expiresAt);
verifyClaim(rawIdentity);
await ctx.reply(`✅ @${rawIdentity} (channel) claimed successfully!`);
return;
}
}
if (origin.type === "chat") {
const chatUsername = origin.chat?.username?.toLowerCase();
const chatId = String(origin.chat?.id ?? "");
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
upsertChallenge(rawIdentity, "group_admin", userId, code, expiresAt);
verifyClaim(rawIdentity);
await ctx.reply(`✅ @${rawIdentity} (group admin) claimed successfully!`);
return;
}
}
await ctx.reply(
"❌ Verification failed — the forward origin did not match @" + rawIdentity + ". Try /claim again."
);
}
export function claimCommands(bot: Composer<BotContext>): void {
bot.use(createConversation(claimConversation, "claim"));
bot.command("claim", async (ctx) => {
if (ctx.chat.type !== "private") {
await ctx.reply("Use /claim in a private chat with me.");
return;
}
await ctx.conversation.enter("claim");
});
bot.command("unclaim", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const identity = ((ctx.match as string | undefined) ?? "").trim().replace(/^@/, "");
if (!identity) {
await ctx.reply("Usage: /unclaim @identity");
return;
}
const removed = deleteClaim(identity, userId);
await ctx.reply(removed ? `✅ @${identity} unclaimed.` : `No claim on @${identity} found.`);
});
bot.command("identities", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const claims = findVerifiedClaimsForUser(userId);
if (claims.length === 0) {
await ctx.reply("No claimed identities. Use /claim @username to add one.");
return;
}
const lines = claims.map(
(c) => `• @${c.claimed_id} (${c.claimed_id_type}) — spending: ${c.allow_spending ? "on" : "off"}`
);
await ctx.reply(lines.join("\n"));
});
}
+110
View File
@@ -0,0 +1,110 @@
import { Composer } from "grammy";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import { upsertContact, deleteContact, listContacts } from "../../db/contacts";
import { getDb } from "../../db/schema";
export function contactCommands(bot: Composer<BotContext>): void {
bot.command("contact", async (ctx) => {
const senderId = ctx.from?.id;
if (!senderId) return;
if (!findUser(senderId)) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
const subcommand = args[0]?.toLowerCase();
switch (subcommand) {
case "add":
await handleAdd(ctx, senderId, args.slice(1));
break;
case "list":
await handleList(ctx, senderId);
break;
case "remove":
await handleRemove(ctx, senderId, args[1]);
break;
default:
await ctx.reply(
"Usage:\n" +
"/contact add <alias> <@username|lightning_address>\n" +
"/contact list\n" +
"/contact remove <alias>"
);
}
});
}
async function handleAdd(ctx: BotContext, ownerId: number, args: string[]): Promise<void> {
const [alias, target] = args;
if (!alias || !target) {
await ctx.reply("Usage: /contact add <alias> <@username|lightning_address>");
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
await ctx.reply("Alias must contain only letters, numbers, underscores, or hyphens.");
return;
}
const cleanTarget = target.replace(/^@/, "");
const numericId = Number(cleanTarget);
let targetUserId: number | null = null;
let targetAddress: string | null = null;
if (!isNaN(numericId)) {
targetUserId = numericId;
const user = findUser(numericId);
targetAddress = user?.spark_address ?? null;
} else if (cleanTarget.includes("@") || cleanTarget.includes(".")) {
// Looks like a Lightning address — store as-is
targetAddress = cleanTarget;
} else {
// Username lookup
const row = getDb()
.prepare("SELECT id, spark_address FROM users WHERE LOWER(username) = ?")
.get(cleanTarget.toLowerCase()) as { id: number; spark_address: string | null } | undefined;
if (row) {
targetUserId = row.id;
targetAddress = row.spark_address ?? null;
} else {
await ctx.reply(`User @${cleanTarget} is not registered with this bot.`);
return;
}
}
upsertContact(ownerId, alias, targetUserId, targetAddress);
await ctx.reply(`✅ Contact saved: "${alias}" → ${target}`);
}
async function handleList(ctx: BotContext, ownerId: number): Promise<void> {
const contacts = listContacts(ownerId);
if (contacts.length === 0) {
await ctx.reply("No contacts saved. Add one with /contact add <alias> <@username>");
return;
}
const lines = contacts.map((c) => {
const dest = c.target_address ?? `user #${c.target_user_id}`;
return `• *${c.alias}* → ${dest}`;
});
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
}
async function handleRemove(ctx: BotContext, ownerId: number, alias?: string): Promise<void> {
if (!alias) {
await ctx.reply("Usage: /contact remove <alias>");
return;
}
const removed = deleteContact(ownerId, alias);
await ctx.reply(removed ? `✅ Contact "${alias}" removed.` : `No contact named "${alias}" found.`);
}
+171
View File
@@ -0,0 +1,171 @@
import { Composer, InlineKeyboard } from "grammy";
import { type Conversation, createConversation } from "@grammyjs/conversations";
import type { BotContext } from "../context";
import { findUser, createUser } from "../../db/users";
import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet";
import { encryptMnemonic } from "../../payments/crypto";
import { publishToRegistry } from "../../payments/registry";
import { createRevealToken, consumeRevealByUserId } from "../../api/onetime";
import { config } from "../../config";
const MIN_PIN_LENGTH = 6;
async function onboardingConversation(
conversation: Conversation<BotContext>,
ctx: BotContext
): Promise<void> {
const userId = ctx.from!.id;
const chatId = ctx.chat!.id;
const username = ctx.from?.username ?? null;
// ── Step 1: PIN entry via DM, deleted immediately ─────────────────────────
const pinPrompt = await ctx.reply(
"Welcome! I'll set up your self-custodial Lightning wallet.\n\n" +
"Choose a PIN (min 6 characters). This encrypts your wallet seed.\n" +
"*Send your PIN now — I'll delete it immediately after reading:*",
{ parse_mode: "Markdown" }
);
const pinMsg = await conversation.waitFor("message:text");
const pin = pinMsg.message.text.trim();
await Promise.allSettled([
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
]);
if (pin.length < MIN_PIN_LENGTH) {
await ctx.reply(`PIN too short (minimum ${MIN_PIN_LENGTH} characters). Try /start again.`);
return;
}
const confirmPrompt = await ctx.reply("Confirm your PIN:");
const confirmMsg = await conversation.waitFor("message:text");
const confirm = confirmMsg.message.text.trim();
await Promise.allSettled([
ctx.api.deleteMessage(chatId, confirmPrompt.message_id),
ctx.api.deleteMessage(chatId, confirmMsg.message.message_id),
]);
if (pin !== confirm) {
await ctx.reply("PINs do not match. Try /start again.");
return;
}
await ctx.reply("Generating your wallet…");
// ── Step 2: create wallet ──────────────────────────────────────────────────
const mnemonic = generateMnemonic();
const sparkAddress = await deriveSparkAddress(mnemonic);
const encryptedMnemonic = encryptMnemonic(mnemonic, pin);
createUser(userId, username, encryptedMnemonic, sparkAddress);
await publishToRegistry(userId, sparkAddress).catch(() => null);
// ── Step 3: offer seed reveal choice ──────────────────────────────────────
if (config.webappUrl) {
const token = createRevealToken(userId, mnemonic);
const revealUrl = `${config.webappUrl}?seedToken=${token}#seed`;
const keyboard = new InlineKeyboard()
.webApp("🔐 View in Mini App (recommended)", revealUrl)
.row()
.text("💬 Show here in chat (auto-deleted)", "seed_in_chat");
await ctx.reply(
"✅ *Wallet created!*\n\n" +
"How would you like to view your 12-word recovery seed?\n\n" +
"• *Mini App* — shown in a secure web view, never stored in chat\n" +
"• *Here in chat* — message deleted automatically after 60 seconds\n\n" +
`Your Lightning address: \`${sparkAddress}\``,
{ parse_mode: "Markdown", reply_markup: keyboard }
);
// Conversation ends here — seed_in_chat callback handles the other branch
return;
}
// ── Fallback: no Mini App configured — always show in chat ────────────────
const seedMsg = await ctx.reply(
"✅ *Wallet created!*\n\n" +
"Your recovery seed (12 words):\n" +
`\`${mnemonic}\`\n\n` +
"📋 *Write this down offline now.* This message deletes in 60 seconds.\n\n" +
`Your Lightning address: \`${sparkAddress}\`\n\n` +
"/unlock — unlock wallet to send\n/balance — check balance",
{ parse_mode: "Markdown" }
);
setTimeout(() => {
ctx.api.deleteMessage(chatId, seedMsg.message_id).catch(() => null);
}, 60_000);
}
export function registerCommands(bot: Composer<BotContext>): void {
bot.use(createConversation(onboardingConversation, "onboarding"));
bot.command("start", async (ctx) => {
if (ctx.chat.type !== "private") {
await ctx.reply("Please DM me to set up your wallet.");
return;
}
const userId = ctx.from!.id;
if (findUser(userId)) {
await ctx.reply(
"You already have a wallet!\n\n" +
"/unlock — unlock wallet\n/balance — check balance\n" +
"/tip — send sats\n/wallet — open Mini App\n/export — export encrypted seed backup"
);
return;
}
await ctx.conversation.enter("onboarding");
});
// "Show here in chat" branch of the seed reveal choice
bot.callbackQuery("seed_in_chat", async (ctx) => {
const userId = ctx.from.id;
const mnemonic = consumeRevealByUserId(userId);
await ctx.answerCallbackQuery();
if (!mnemonic || mnemonic.startsWith("\0")) {
await ctx.reply("This seed has already been revealed or expired. Use /export to get your encrypted backup.");
return;
}
const user = findUser(userId);
const seedMsg = await ctx.reply(
"Your recovery seed (12 words):\n" +
`\`${mnemonic}\`\n\n` +
"📋 *Write this down offline now.* This message deletes in 60 seconds.",
{ parse_mode: "Markdown" }
);
setTimeout(() => {
ctx.api.deleteMessage(ctx.chat!.id, seedMsg.message_id).catch(() => null);
}, 60_000);
});
bot.command("export", async (ctx) => {
if (ctx.chat.type !== "private") {
await ctx.reply("Use /export in a private chat with me.");
return;
}
const user = findUser(ctx.from!.id);
if (!user) {
await ctx.reply("No wallet found. Use /start to create one.");
return;
}
await ctx.reply(
"🔐 *Encrypted wallet backup*\n\n" +
"```\n" + user.encrypted_mnemonic + "\n```\n\n" +
"Decrypt offline with your PIN using AES-256-GCM + scrypt.\n" +
"Format: `salt(32B) + iv(12B) + authTag(16B) + ciphertext` — all base64.",
{ parse_mode: "Markdown" }
);
});
}
+164
View File
@@ -0,0 +1,164 @@
import { Composer } from "grammy";
import type { BotContext } from "../context";
import { findUser, updateUnlockDuration } from "../../db/users";
import { setAllowSpending, findClaimByIdentity } from "../../db/claims";
import { publishToRegistry } from "../../payments/registry";
import { getDb } from "../../db/schema";
const DURATION_MAP: Record<string, number> = {
"15m": 15 * 60,
"30m": 30 * 60,
"1h": 3600,
"4h": 4 * 3600,
"8h": 8 * 3600,
"24h": 24 * 3600,
"48h": 48 * 3600,
};
export function settingsCommands(bot: Composer<BotContext>): void {
bot.command("settings", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const user = findUser(userId);
if (!user) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
const key = args[0]?.toLowerCase();
if (!key) {
await ctx.reply(
"Settings:\n" +
"/settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx> — default unlock window\n" +
"/settings registry <on|off> — federated registry opt-in\n" +
`/settings spending <@identity> <on|off> — allow/deny spending from identity\n\n` +
`Current unlock duration: ${formatDuration(user.unlock_duration)}`
);
return;
}
switch (key) {
case "unlock_duration":
await handleUnlockDuration(ctx, userId, args[1]);
break;
case "registry":
await handleRegistry(ctx, userId, user, args[1]);
break;
case "spending":
await handleSpending(ctx, userId, args[1], args[2]);
break;
default:
await ctx.reply(`Unknown setting: ${key}`);
}
});
bot.command("history", async (ctx) => {
const userId = ctx.from?.id;
if (!userId) return;
const rows = getDb()
.prepare(
`SELECT id, amount_sats, status, recipient_address, created_at
FROM pending_transactions
WHERE initiator_user_id = ?
ORDER BY created_at DESC LIMIT 10`
)
.all(userId) as {
id: string;
amount_sats: number;
status: string;
recipient_address: string | null;
created_at: string;
}[];
if (rows.length === 0) {
await ctx.reply("No transaction history yet.");
return;
}
const lines = rows.map((r) => {
const statusIcon = r.status === "done" ? "✅" : r.status === "failed" ? "❌" : "⏳";
const dest = r.recipient_address?.slice(0, 20) ?? "unknown";
return `${statusIcon} ${r.amount_sats} sats → ${dest}… (${r.created_at.slice(0, 10)})`;
});
await ctx.reply(lines.join("\n"));
});
}
async function handleUnlockDuration(ctx: BotContext, userId: number, value?: string): Promise<void> {
if (!value) {
await ctx.reply("Usage: /settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx>");
return;
}
const lower = value.toLowerCase();
if (lower === "1tx") {
// Store 0 as a sentinel for tx-only mode preference
updateUnlockDuration(userId, 0);
await ctx.reply("Default unlock mode set to: 1 transaction.");
return;
}
const seconds = DURATION_MAP[lower];
if (!seconds) {
await ctx.reply(`Unknown duration. Choose one of: ${Object.keys(DURATION_MAP).join(", ")}, 1tx`);
return;
}
updateUnlockDuration(userId, seconds);
await ctx.reply(`Default unlock duration set to ${value}.`);
}
async function handleRegistry(
ctx: BotContext,
userId: number,
user: { spark_address: string | null },
value?: string
): Promise<void> {
if (value === "on") {
if (user.spark_address) {
await publishToRegistry(userId, user.spark_address);
}
await ctx.reply("✅ Federated registry publishing enabled.");
} else if (value === "off") {
// TODO: send a delete request to the remote registry if supported
await ctx.reply("Registry publishing disabled. Your entry remains in remote caches until they expire.");
} else {
await ctx.reply("Usage: /settings registry on|off");
}
}
async function handleSpending(
ctx: BotContext,
userId: number,
identity?: string,
value?: string
): Promise<void> {
if (!identity || !value) {
await ctx.reply("Usage: /settings spending <@identity> <on|off>");
return;
}
const clean = identity.replace(/^@/, "");
const claim = findClaimByIdentity(clean);
if (!claim || claim.owned_by_user_id !== userId || !claim.verified_at) {
await ctx.reply(`No verified claim on @${clean}. Use /claim first.`);
return;
}
const allow = value.toLowerCase() === "on";
setAllowSpending(clean, userId, allow);
await ctx.reply(`Spending from @${clean}: ${allow ? "enabled" : "disabled"}.`);
}
function formatDuration(seconds: number): string {
if (seconds === 0) return "1tx";
if (seconds < 3600) return `${seconds / 60}m`;
return `${seconds / 3600}h`;
}
+207
View File
@@ -0,0 +1,207 @@
import { Composer } from "grammy";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import { getSession, consumeTx } from "../../payments/session";
import { sendPayment } from "../../payments/wallet";
import { resolveRecipient } from "../../payments/registry";
import { createPending, findPending, updateStatus, claimForRecipient, type PendingTransaction } from "../../db/pending";
// ─── Core payment execution ────────────────────────────────────────────────────
export async function executePay(
userId: number,
recipientAddress: string,
amountSats: number
): Promise<{ txId: string; feeSats: number }> {
const session = getSession(userId);
if (!session) throw new Error("wallet_locked");
const result = await sendPayment(userId, recipientAddress, amountSats);
consumeTx(userId);
return result;
}
// ─── Process a queued pending transaction ─────────────────────────────────────
export async function processPending(ctx: BotContext, tx: PendingTransaction): Promise<void> {
if (!tx.recipient_address) {
updateStatus(tx.id, "failed");
return;
}
updateStatus(tx.id, "processing");
try {
await executePay(tx.initiator_user_id, tx.recipient_address, tx.amount_sats);
updateStatus(tx.id, "done");
// Confirm back in the group if we know which chat
if (tx.group_chat_id) {
try {
const recipientLabel =
tx.recipient_user_id ? `user #${tx.recipient_user_id}` : tx.recipient_address;
await ctx.api.sendMessage(
tx.group_chat_id,
`${tx.amount_sats} sats sent to ${recipientLabel}`
);
} catch {
// group message is best-effort
}
}
} catch (err) {
updateStatus(tx.id, "failed");
console.error("[tip] processPending failed:", err);
}
}
// ─── /tip command ─────────────────────────────────────────────────────────────
export function tipCommands(bot: Composer<BotContext>): void {
bot.command("tip", async (ctx) => {
const senderId = ctx.from?.id;
if (!senderId) return;
const sender = findUser(senderId);
if (!sender) {
await ctx.reply("You need a wallet first. DM me /start to set one up.");
return;
}
// Parse amount (and optional target) from command args
const args = (ctx.match as string | undefined)?.trim().split(/\s+/) ?? [];
const amountSats = args[0] ? parseInt(args[0], 10) : NaN;
const MAX_SATS = 21_000_000 * 100_000_000; // 21M BTC in sats
if (isNaN(amountSats) || amountSats <= 0 || amountSats > MAX_SATS) {
await ctx.reply("Usage: /tip <amount_sats> [@user|alias]\nExample: /tip 100");
return;
}
const targetArg = args[1] ?? null;
const replyToUserId = ctx.message?.reply_to_message?.from?.id;
const groupChatId = ctx.chat?.type !== "private" ? ctx.chat?.id ?? null : null;
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
senderId,
targetArg,
replyToUserId
);
if (!recipientAddress) {
await ctx.reply(
"Recipient not found or not registered. Share this link with them: t.me/" +
ctx.me.username
);
return;
}
const session = getSession(senderId);
if (!session) {
// Queue the transaction and ask user to unlock
const txId = createPending({
initiatorUserId: senderId,
recipientUserId: recipientUserId,
recipientAddress,
amountSats,
initiatedVia: "command",
groupChatId,
});
await ctx.reply(
`Wallet is locked. DM me /unlock to send ${amountSats} sats.\nTransaction queued (expires in 2 min, ref: \`${txId.slice(0, 8)}\`).`,
{ parse_mode: "Markdown" }
);
// DM the sender if we're in a group
if (groupChatId) {
try {
await ctx.api.sendMessage(
senderId,
`You have a pending tip of ${amountSats} sats. /unlock to confirm.`
);
} catch {
// DM might be blocked — the group message already told them
}
}
return;
}
// Wallet unlocked — send immediately
try {
await executePay(senderId, recipientAddress, amountSats);
const recipientLabel = replyToUserId
? `@${ctx.message?.reply_to_message?.from?.username ?? replyToUserId}`
: targetArg ?? recipientAddress;
await ctx.reply(`${amountSats} sats sent to ${recipientLabel}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown error";
await ctx.reply(`❌ Payment failed: ${msg}`);
}
});
// Callback handler for claim button (inline mode without alias)
bot.callbackQuery(/^claim:(.+)$/, async (ctx) => {
const txId = ctx.match[1];
const claimerId = ctx.from.id;
// Reject self-claims: sender should not claim their own tip
const txCheck = findPending(txId);
if (txCheck?.initiator_user_id === claimerId) {
await ctx.answerCallbackQuery("You cannot claim your own tip.");
return;
}
const claimer = findUser(claimerId);
if (!claimer?.spark_address) {
await ctx.answerCallbackQuery({
text: "You need a wallet to claim this. DM me /start to set one up.",
show_alert: true,
});
return;
}
// Atomic claim: only one concurrent caller can succeed (TOCTOU-safe)
const won = claimForRecipient(txId, claimerId, claimer.spark_address);
if (!won) {
await ctx.answerCallbackQuery("This tip has already been claimed or expired.");
return;
}
// We now own the tx exclusively (status = 'processing' in DB)
const tx = findPending(txId)!;
const session = getSession(tx.initiator_user_id);
if (!session) {
// Revert to awaiting_unlock so the sender can still pay on /unlock
updateStatus(txId, "awaiting_unlock");
await ctx.answerCallbackQuery("Sender needs to unlock their wallet first.");
try {
await ctx.api.sendMessage(
tx.initiator_user_id,
`@${ctx.from.username ?? claimerId} wants to claim your ${tx.amount_sats} sat tip. /unlock to send.`
);
} catch {
// best-effort DM
}
return;
}
try {
await executePay(tx.initiator_user_id, claimer.spark_address, tx.amount_sats);
updateStatus(txId, "done");
await ctx.editMessageText(
`${tx.amount_sats} sats claimed by @${ctx.from.username ?? claimerId}`
);
await ctx.answerCallbackQuery("Payment sent!");
} catch {
updateStatus(txId, "failed");
await ctx.answerCallbackQuery("Payment failed. Try again later.");
}
});
}
+156
View File
@@ -0,0 +1,156 @@
import { Composer } from "grammy";
import { type Conversation, createConversation } from "@grammyjs/conversations";
import type { BotContext } from "../context";
import { findUser, updateUnlockDuration } from "../../db/users";
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../../api/ratelimit";
import { getWalletInfo } from "../../payments/wallet";
import { decryptMnemonic } from "../../payments/crypto";
import { createSession, destroySession, getSession } from "../../payments/session";
import { connectWallet, disconnectWallet } from "../../payments/wallet";
import { findPendingForUser } from "../../db/pending";
import { processPending } from "./tip";
function parseDuration(raw: string): { seconds: number; policy: "timed" | "tx-only"; txCount?: number } | null {
const txMatch = raw.match(/^(\d+)?tx$/i);
if (txMatch) {
const count = txMatch[1] ? parseInt(txMatch[1], 10) : 1;
return { seconds: 86400, policy: "tx-only", txCount: count };
}
const timeMatch = raw.match(/^(\d+)(s|m|h|d)$/i);
if (!timeMatch) return null;
const value = parseInt(timeMatch[1], 10);
const unit = timeMatch[2].toLowerCase();
const multiplier: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
return { seconds: value * multiplier[unit], policy: "timed" };
}
async function unlockConversation(
conversation: Conversation<BotContext>,
ctx: BotContext
): Promise<void> {
const userId = ctx.from!.id;
const user = findUser(userId);
if (!user) {
await ctx.reply("No wallet found. Use /start first.");
return;
}
const args = ctx.match as string | undefined;
let durationSeconds = user.unlock_duration;
let policy: "timed" | "tx-only" = "timed";
let txCount: number | undefined;
if (args?.trim()) {
const parsed = parseDuration(args.trim());
if (!parsed) {
await ctx.reply("Unknown duration format. Examples: /unlock 4h | /unlock 1tx | /unlock 30m");
return;
}
({ seconds: durationSeconds, policy } = parsed);
txCount = parsed.txCount;
}
try {
checkPinRateLimit(userId);
} catch (err) {
if (err instanceof RateLimitError) {
await ctx.reply(`🚫 ${err.message}`);
return;
}
throw err;
}
const chatId = ctx.chat!.id;
const pinPrompt = await ctx.reply(
"🔐 Enter your PIN — I'll delete it immediately after reading:"
);
const pinMsg = await conversation.waitFor("message:text");
const pin = pinMsg.message.text.trim();
// Delete both the prompt and the user's PIN before doing anything else
await Promise.allSettled([
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
]);
let mnemonic: string;
try {
mnemonic = decryptMnemonic(user.encrypted_mnemonic, pin);
recordPinSuccess(userId);
} catch {
recordPinFailure(userId);
await ctx.reply("❌ Incorrect PIN.");
return;
}
await connectWallet(userId, mnemonic);
createSession(userId, mnemonic, policy, durationSeconds, txCount);
const label =
policy === "tx-only"
? `${txCount ?? 1} transaction(s)`
: durationSeconds >= 3600
? `${durationSeconds / 3600}h`
: `${durationSeconds / 60}m`;
await ctx.reply(`✅ Wallet unlocked for ${label}.`);
// Process any pending transactions
const pending = findPendingForUser(userId);
if (pending.length > 0) {
await ctx.reply(`Processing ${pending.length} pending transaction(s)...`);
for (const tx of pending) {
await processPending(ctx, tx);
}
}
}
export function unlockCommands(bot: Composer<BotContext>): void {
bot.use(createConversation(unlockConversation, "unlock"));
bot.command("unlock", async (ctx) => {
if (ctx.chat.type !== "private") {
await ctx.reply("Please use /unlock in a private chat with me (for PIN security).");
return;
}
await ctx.conversation.enter("unlock");
});
bot.command("lock", async (ctx) => {
const userId = ctx.from!.id;
const had = destroySession(userId);
await disconnectWallet(userId);
if (had) {
await ctx.reply("🔒 Wallet locked.");
} else {
await ctx.reply("Wallet is already locked.");
}
});
bot.command("balance", async (ctx) => {
const userId = ctx.from!.id;
const session = getSession(userId);
if (!session) {
await ctx.reply("Wallet is locked. Use /unlock first.");
return;
}
const info = await getWalletInfo(userId);
if (!info) {
await ctx.reply("Could not fetch balance. Try /unlock again.");
return;
}
await ctx.reply(
`⚡ Balance: *${info.balanceSats} sats*\nAddress: \`${info.sparkAddress}\``,
{ parse_mode: "Markdown" }
);
});
}
+8
View File
@@ -0,0 +1,8 @@
import { Context, SessionFlavor } from "grammy";
import { ConversationFlavor } from "@grammyjs/conversations";
export interface SessionData {
// intentionally empty — conversations plugin manages its own state
}
export type BotContext = Context & SessionFlavor<SessionData> & ConversationFlavor;
+131
View File
@@ -0,0 +1,131 @@
import { Bot, InlineKeyboard, session } from "grammy";
import { conversations } from "@grammyjs/conversations";
import { run } from "@grammyjs/runner";
import { config } from "../config";
import { getDb } from "../db/schema";
import { getAllUserIds, findUser, updateUsername } from "../db/users";
import { expireStale } from "../db/pending";
import { startSweep, stopSweep, destroySession, getAllActiveUserIds } from "../payments/session";
import { disconnectWallet } from "../payments/wallet";
import { startApiServer } from "../api/server";
import type { BotContext, SessionData } from "./context";
import { registerCommands } from "./commands/register";
import { unlockCommands } from "./commands/unlock";
import { tipCommands } from "./commands/tip";
import { contactCommands } from "./commands/contact";
import { claimCommands } from "./commands/claim";
import { settingsCommands } from "./commands/settings";
import { inlineTipHandler } from "./inline/tip";
// ─── Bootstrap ────────────────────────────────────────────────────────────────
getDb();
const bot = new Bot<BotContext>(config.botToken);
// ─── Middleware ───────────────────────────────────────────────────────────────
bot.use(session<SessionData, BotContext>({ initial: () => ({}) }));
bot.use(conversations());
// Keep username up-to-date on every interaction
bot.use(async (ctx, next) => {
const userId = ctx.from?.id;
if (userId) {
const user = findUser(userId);
if (user && ctx.from?.username !== user.username) {
updateUsername(userId, ctx.from?.username ?? null);
}
}
await next();
});
// ─── Commands ─────────────────────────────────────────────────────────────────
registerCommands(bot);
unlockCommands(bot);
tipCommands(bot);
contactCommands(bot);
claimCommands(bot);
settingsCommands(bot);
inlineTipHandler(bot);
// /wallet — open the Mini App
bot.command("wallet", async (ctx) => {
const webappUrl = config.webappUrl;
if (!webappUrl) {
await ctx.reply("Mini App is not configured. Set WEBAPP_URL in the bot environment.");
return;
}
const keyboard = new InlineKeyboard().webApp("Open Wallet", webappUrl);
await ctx.reply("Tap to open your wallet:", { reply_markup: keyboard });
});
// ─── Error handler ────────────────────────────────────────────────────────────
bot.catch((err) => {
console.error("[bot] Unhandled error:", err.message);
console.error(err.error);
});
// ─── Session sweep ────────────────────────────────────────────────────────────
startSweep(async (userId) => {
await disconnectWallet(userId).catch(() => null);
console.log(`[session] Expired for user ${userId}`);
});
// ─── Stale pending transaction cleanup ────────────────────────────────────────
setInterval(() => {
const count = expireStale();
if (count > 0) console.log(`[pending] Expired ${count} stale transaction(s)`);
}, 60_000).unref?.();
// ─── Startup notification ─────────────────────────────────────────────────────
async function notifyUsersOnRestart(): Promise<void> {
const userIds = getAllUserIds();
const message = "🔄 Bot restarted — your wallet session was cleared. /unlock to continue.";
for (const userId of userIds) {
try {
await bot.api.sendMessage(userId, message);
} catch {
// User may have blocked the bot or not started it yet
}
}
}
// ─── Graceful shutdown ────────────────────────────────────────────────────────
async function shutdown(runner: { stop(): void }): Promise<void> {
console.log("[bot] Shutting down...");
runner.stop();
stopSweep();
for (const userId of getAllActiveUserIds()) {
destroySession(userId);
await disconnectWallet(userId).catch(() => null);
}
getDb().close();
process.exit(0);
}
// ─── Start ────────────────────────────────────────────────────────────────────
const runner = run(bot);
process.once("SIGINT", () => shutdown(runner));
process.once("SIGTERM", () => shutdown(runner));
// Start the Mini App API server alongside the bot
startApiServer().catch((err) => {
console.error("[api] Failed to start:", err.message);
process.exit(1);
});
console.log(`[bot] ${config.botInstanceName} started.`);
notifyUsersOnRestart().catch((err) =>
console.warn("[bot] Restart notification error:", err.message)
);
+167
View File
@@ -0,0 +1,167 @@
import { Composer, InlineKeyboard } from "grammy";
import type { BotContext } from "../context";
import { findUser } from "../../db/users";
import { getSession } from "../../payments/session";
import { resolveRecipient } from "../../payments/registry";
import { createPending, findPending, updateStatus } from "../../db/pending";
import { executePay } from "../commands/tip";
// ─── Inline query: @mybot tip 100 [alias] ────────────────────────────────────
export function inlineTipHandler(bot: Composer<BotContext>): void {
bot.on("inline_query", async (ctx) => {
const senderId = ctx.from.id;
const text = ctx.inlineQuery.query.trim();
// Only handle "tip <amount> [alias]" pattern
const match = text.match(/^tip\s+(\d+)(?:\s+(\S+))?$/i);
if (!match) {
await ctx.answerInlineQuery([], {
cache_time: 0,
button: { text: "How to use: tip <amount> [alias]", start_parameter: "help" },
});
return;
}
const amountSats = parseInt(match[1], 10);
const alias = match[2] ?? null;
if (!findUser(senderId)) {
await ctx.answerInlineQuery([
{
type: "article",
id: "not_registered",
title: "Wallet not set up",
description: "DM me /start to create your wallet",
input_message_content: { message_text: "I need to set up my wallet first." },
},
]);
return;
}
if (!alias) {
// No alias — post a "claim" message with a button
const txId = createPending({
initiatorUserId: senderId,
recipientUserId: null,
recipientAddress: null,
amountSats,
initiatedVia: "inline",
groupChatId: null, // resolved in chosen_inline_result
});
const keyboard = new InlineKeyboard().text(`⚡ Claim ${amountSats} sats`, `claim:${txId}`);
await ctx.answerInlineQuery([
{
type: "article",
id: `tip_claim_${txId}`,
title: `⚡ Send ${amountSats} sats — anyone can claim`,
description: "Tap to post. First person to tap Claim receives the payment.",
input_message_content: {
message_text: `${amountSats} sats up for grabs — tap to claim!`,
},
reply_markup: keyboard,
},
]);
return;
}
// Resolve alias to an address
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
senderId,
alias
);
if (!recipientAddress) {
await ctx.answerInlineQuery([
{
type: "article",
id: "alias_not_found",
title: `Unknown alias: ${alias}`,
description: "Add contacts with /contact add",
input_message_content: {
message_text: `Could not find alias "${alias}". Add contacts with /contact add.`,
},
},
]);
return;
}
const txId = createPending({
initiatorUserId: senderId,
recipientUserId,
recipientAddress,
amountSats,
initiatedVia: "inline",
groupChatId: null,
});
await ctx.answerInlineQuery([
{
type: "article",
id: `tip_direct_${txId}`,
title: `⚡ Send ${amountSats} sats to ${alias}`,
description: "Tap to send",
input_message_content: {
message_text: `⚡ Sending ${amountSats} sats to ${alias}`,
},
},
]);
});
// ─── Chosen inline result: actually execute the payment ──────────────────
bot.on("chosen_inline_result", async (ctx) => {
const resultId = ctx.chosenInlineResult.result_id;
const senderId = ctx.from.id;
// Only handle direct tip results (not claim results — those use callback buttons)
if (!resultId.startsWith("tip_direct_")) return;
const txId = resultId.replace("tip_direct_", "");
const tx = findPending(txId);
if (!tx || tx.status !== "awaiting_unlock") return;
const session = getSession(senderId);
if (!session) {
// Wallet locked — update tx and notify sender
updateStatus(txId, "awaiting_unlock"); // already set, but make explicit
try {
await ctx.api.sendMessage(
senderId,
`Wallet locked. /unlock to send ${tx.amount_sats} sats (pending for 2 min).`
);
} catch {
// DM might be blocked
}
return;
}
updateStatus(txId, "processing");
try {
await executePay(senderId, tx.recipient_address!, tx.amount_sats);
updateStatus(txId, "done");
// Edit the posted message to show success (requires inline_message_id)
const inlineMsgId = ctx.chosenInlineResult.inline_message_id;
if (inlineMsgId) {
try {
await ctx.api.editMessageTextInline(
inlineMsgId,
`${tx.amount_sats} sats sent!`
);
} catch {
// best-effort edit
}
}
} catch {
updateStatus(txId, "failed");
}
});
}
+34
View File
@@ -0,0 +1,34 @@
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}
function optional(name: string, fallback = ""): string {
return process.env[name] ?? fallback;
}
function optionalInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = parseInt(raw, 10);
if (isNaN(parsed)) throw new Error(`Env var ${name} must be an integer`);
return parsed;
}
export const config = {
botToken: required("BOT_TOKEN"),
breezApiKey: optional("BREEZ_API_KEY"), // optional until real SDK is wired in
databaseUrl: optional("DATABASE_URL", "sqlite:./data/bot.db"),
registryUrl: optional("REGISTRY_URL"),
registryWriteKey: optional("REGISTRY_WRITE_KEY"),
registryReadKey: optional("REGISTRY_READ_KEY"),
sessionSweepIntervalMs: optionalInt("SESSION_SWEEP_INTERVAL_MS", 60_000),
botInstanceName: optional("BOT_INSTANCE_NAME", "mybot"),
nodeEnv: optional("NODE_ENV", "development"),
// Mini App / Web API
webappPort: optionalInt("WEBAPP_PORT", 3000),
webappUrl: optional("WEBAPP_URL"), // public HTTPS URL served to users
} as const;
export type Config = typeof config;
+81
View File
@@ -0,0 +1,81 @@
import { getDb } from "./schema";
export type ClaimedIdType = "username" | "channel" | "group_admin";
export interface IdentityClaim {
id: number;
claimed_id: string;
claimed_id_type: ClaimedIdType;
owned_by_user_id: number;
allow_spending: number; // 1 = true, 0 = false (SQLite boolean)
challenge_code: string | null;
challenge_expiry: string | null;
verified_at: string | null;
}
export function findClaimByIdentity(claimedId: string): IdentityClaim | undefined {
return getDb()
.prepare("SELECT * FROM identity_claims WHERE claimed_id = ?")
.get(claimedId) as IdentityClaim | undefined;
}
export function findVerifiedClaimsForUser(userId: number): IdentityClaim[] {
return getDb()
.prepare(
"SELECT * FROM identity_claims WHERE owned_by_user_id = ? AND verified_at IS NOT NULL"
)
.all(userId) as IdentityClaim[];
}
export function upsertChallenge(
claimedId: string,
claimedIdType: ClaimedIdType,
ownerUserId: number,
challengeCode: string,
expiresAt: Date
): void {
getDb()
.prepare(
`INSERT INTO identity_claims
(claimed_id, claimed_id_type, owned_by_user_id, challenge_code, challenge_expiry)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (claimed_id) DO UPDATE SET
claimed_id_type = excluded.claimed_id_type,
owned_by_user_id = excluded.owned_by_user_id,
challenge_code = excluded.challenge_code,
challenge_expiry = excluded.challenge_expiry,
verified_at = NULL`
)
.run(claimedId, claimedIdType, ownerUserId, challengeCode, expiresAt.toISOString());
}
export function verifyClaim(claimedId: string): void {
getDb()
.prepare(
`UPDATE identity_claims
SET verified_at = datetime('now'), challenge_code = NULL, challenge_expiry = NULL
WHERE claimed_id = ?`
)
.run(claimedId);
}
export function deleteClaim(claimedId: string, ownerUserId: number): boolean {
const result = getDb()
.prepare(
"DELETE FROM identity_claims WHERE claimed_id = ? AND owned_by_user_id = ?"
)
.run(claimedId, ownerUserId);
return result.changes > 0;
}
export function setAllowSpending(
claimedId: string,
ownerUserId: number,
allow: boolean
): void {
getDb()
.prepare(
"UPDATE identity_claims SET allow_spending = ? WHERE claimed_id = ? AND owned_by_user_id = ?"
)
.run(allow ? 1 : 0, claimedId, ownerUserId);
}
+47
View File
@@ -0,0 +1,47 @@
import { getDb } from "./schema";
export interface Contact {
id: number;
owner_user_id: number;
alias: string;
target_user_id: number | null;
target_address: string | null;
}
export function findContact(ownerUserId: number, alias: string): Contact | undefined {
return getDb()
.prepare(
"SELECT * FROM contacts WHERE owner_user_id = ? AND alias = ? COLLATE NOCASE"
)
.get(ownerUserId, alias) as Contact | undefined;
}
export function listContacts(ownerUserId: number): Contact[] {
return getDb()
.prepare("SELECT * FROM contacts WHERE owner_user_id = ? ORDER BY alias")
.all(ownerUserId) as Contact[];
}
export function upsertContact(
ownerUserId: number,
alias: string,
targetUserId: number | null,
targetAddress: string | null
): void {
getDb()
.prepare(
`INSERT INTO contacts (owner_user_id, alias, target_user_id, target_address)
VALUES (?, ?, ?, ?)
ON CONFLICT (owner_user_id, alias) DO UPDATE SET
target_user_id = excluded.target_user_id,
target_address = excluded.target_address`
)
.run(ownerUserId, alias, targetUserId, targetAddress);
}
export function deleteContact(ownerUserId: number, alias: string): boolean {
const result = getDb()
.prepare("DELETE FROM contacts WHERE owner_user_id = ? AND alias = ? COLLATE NOCASE")
.run(ownerUserId, alias);
return result.changes > 0;
}
+106
View File
@@ -0,0 +1,106 @@
import { getDb } from "./schema";
import { randomUUID } from "crypto";
export type TxStatus = "awaiting_unlock" | "processing" | "done" | "expired" | "failed";
export type TxInitiatedVia = "command" | "inline";
export interface PendingTransaction {
id: string;
initiator_user_id: number;
recipient_user_id: number | null;
recipient_address: string | null;
amount_sats: number;
status: TxStatus;
initiated_via: TxInitiatedVia;
group_chat_id: number | null;
created_at: string;
expires_at: string;
}
const TX_TTL_MS = 2 * 60 * 1000; // 2 minutes
export function createPending(opts: {
initiatorUserId: number;
recipientUserId: number | null;
recipientAddress: string | null;
amountSats: number;
initiatedVia: TxInitiatedVia;
groupChatId: number | null;
}): string {
const id = randomUUID();
const expiresAt = new Date(Date.now() + TX_TTL_MS).toISOString();
getDb()
.prepare(
`INSERT INTO pending_transactions
(id, initiator_user_id, recipient_user_id, recipient_address,
amount_sats, initiated_via, group_chat_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
opts.initiatorUserId,
opts.recipientUserId,
opts.recipientAddress,
opts.amountSats,
opts.initiatedVia,
opts.groupChatId,
expiresAt
);
return id;
}
export function findPending(id: string): PendingTransaction | undefined {
return getDb()
.prepare("SELECT * FROM pending_transactions WHERE id = ?")
.get(id) as PendingTransaction | undefined;
}
export function findPendingForUser(initiatorUserId: number): PendingTransaction[] {
return getDb()
.prepare(
`SELECT * FROM pending_transactions
WHERE initiator_user_id = ? AND status = 'awaiting_unlock'
AND expires_at > datetime('now')
ORDER BY created_at`
)
.all(initiatorUserId) as PendingTransaction[];
}
export function updateStatus(id: string, status: TxStatus): void {
getDb()
.prepare("UPDATE pending_transactions SET status = ? WHERE id = ?")
.run(status, id);
}
/**
* Atomically claim a pending transaction for a recipient.
* Uses a single UPDATE with a WHERE status guard so two concurrent callers
* cannot both succeed — only the one whose UPDATE lands first gets changes = 1.
* Returns true iff this caller won the claim.
*/
export function claimForRecipient(
txId: string,
recipientUserId: number,
recipientAddress: string
): boolean {
const result = getDb()
.prepare(
`UPDATE pending_transactions
SET recipient_user_id = ?, recipient_address = ?, status = 'processing'
WHERE id = ? AND status = 'awaiting_unlock' AND expires_at > datetime('now')`
)
.run(recipientUserId, recipientAddress, txId);
return result.changes === 1;
}
export function expireStale(): number {
const result = getDb()
.prepare(
`UPDATE pending_transactions SET status = 'expired'
WHERE status = 'awaiting_unlock' AND expires_at <= datetime('now')`
)
.run();
return result.changes;
}
+86
View File
@@ -0,0 +1,86 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { config } from "../config";
let _db: Database.Database | null = null;
export function getDb(): Database.Database {
if (_db) return _db;
const url = config.databaseUrl;
if (!url.startsWith("sqlite:")) {
throw new Error("Only SQLite is supported via this module. Use DATABASE_URL=sqlite:./path/to/db");
}
const filePath = url.slice("sqlite:".length);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
_db = new Database(filePath);
_db.pragma("journal_mode = WAL");
_db.pragma("foreign_keys = ON");
applySchema(_db);
return _db;
}
function applySchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT,
encrypted_mnemonic TEXT NOT NULL,
spark_address TEXT,
unlock_duration INTEGER NOT NULL DEFAULT 3600,
onboarded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS identity_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claimed_id TEXT NOT NULL,
claimed_id_type TEXT NOT NULL CHECK (claimed_id_type IN ('username', 'channel', 'group_admin')),
owned_by_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
allow_spending INTEGER NOT NULL DEFAULT 1,
challenge_code TEXT,
challenge_expiry TEXT,
verified_at TEXT,
UNIQUE (claimed_id)
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
alias TEXT NOT NULL,
target_user_id INTEGER,
target_address TEXT,
UNIQUE (owner_user_id, alias)
);
CREATE TABLE IF NOT EXISTS pending_transactions (
id TEXT PRIMARY KEY,
initiator_user_id INTEGER NOT NULL REFERENCES users(id),
recipient_user_id INTEGER,
recipient_address TEXT,
amount_sats INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'awaiting_unlock'
CHECK (status IN ('awaiting_unlock', 'processing', 'done', 'expired', 'failed')),
initiated_via TEXT NOT NULL CHECK (initiated_via IN ('command', 'inline')),
group_chat_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS federated_registry (
telegram_id INTEGER PRIMARY KEY,
spark_address TEXT NOT NULL,
published_by TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
export function closeDb(): void {
_db?.close();
_db = null;
}
+61
View File
@@ -0,0 +1,61 @@
import { getDb } from "./schema";
export interface User {
id: number;
username: string | null;
encrypted_mnemonic: string;
spark_address: string | null;
unlock_duration: number;
onboarded_at: string;
}
export function findUser(userId: number): User | undefined {
return getDb()
.prepare("SELECT * FROM users WHERE id = ?")
.get(userId) as User | undefined;
}
export function createUser(
userId: number,
username: string | null,
encryptedMnemonic: string,
sparkAddress: string | null
): void {
getDb()
.prepare(
`INSERT INTO users (id, username, encrypted_mnemonic, spark_address)
VALUES (?, ?, ?, ?)`
)
.run(userId, username, encryptedMnemonic, sparkAddress);
}
export function updateSparkAddress(userId: number, sparkAddress: string): void {
getDb()
.prepare("UPDATE users SET spark_address = ? WHERE id = ?")
.run(sparkAddress, userId);
}
export function updateUsername(userId: number, username: string | null): void {
getDb()
.prepare("UPDATE users SET username = ? WHERE id = ?")
.run(username, userId);
}
export function updateUnlockDuration(userId: number, seconds: number): void {
getDb()
.prepare("UPDATE users SET unlock_duration = ? WHERE id = ?")
.run(seconds, userId);
}
export function updateEncryptedMnemonic(userId: number, encrypted: string): void {
getDb()
.prepare("UPDATE users SET encrypted_mnemonic = ? WHERE id = ?")
.run(encrypted, userId);
}
export function getAllUserIds(): number[] {
const rows = getDb()
.prepare("SELECT id FROM users")
.all() as { id: number }[];
return rows.map((r) => r.id);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* AES-256-GCM encryption for user mnemonics.
* The PIN/passphrase is stretched with scrypt before use as an AES key.
*
* Encrypted format (base64-encoded):
* salt(32) + iv(12) + authTag(16) + ciphertext
*/
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
const SALT_LEN = 32;
const IV_LEN = 12;
const KEY_LEN = 32; // AES-256
const TAG_LEN = 16;
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
function deriveKey(pin: string, salt: Buffer): Buffer {
return scryptSync(pin, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }) as Buffer;
}
export function encryptMnemonic(mnemonic: string, pin: string): string {
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const key = deriveKey(pin, salt);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(mnemonic, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const blob = Buffer.concat([salt, iv, authTag, encrypted]);
return blob.toString("base64");
}
export function decryptMnemonic(encryptedBlob: string, pin: string): string {
const blob = Buffer.from(encryptedBlob, "base64");
const salt = blob.subarray(0, SALT_LEN);
const iv = blob.subarray(SALT_LEN, SALT_LEN + IV_LEN);
const authTag = blob.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
const ciphertext = blob.subarray(SALT_LEN + IV_LEN + TAG_LEN);
const key = deriveKey(pin, salt);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
try {
return decipher.update(ciphertext) + decipher.final("utf8");
} catch {
throw new Error("Invalid PIN or corrupted data.");
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Federated registry client.
*
* Reads from / writes to a remote HTTP registry and maintains a local cache
* in the federated_registry SQLite table. The local DB always takes priority.
*/
import { getDb } from "../db/schema";
import { config } from "../config";
// Permit spark1… addresses, Lightning addresses (user@domain), and on-chain bech32
const ADDRESS_RE = /^(spark1[a-z0-9]{10,}|[^@\s]{1,64}@[^@\s]{1,255}|bc1[a-z0-9]{25,87}|[13][a-zA-Z0-9]{25,34})$/;
function isValidAddress(addr: string): boolean {
return addr.length <= 400 && ADDRESS_RE.test(addr);
}
export interface RegistryEntry {
telegram_id: number;
spark_address: string;
published_by: string;
updated_at: string;
}
// ─── Local cache (federated_registry table) ───────────────────────────────────
export function lookupInRegistry(telegramId: number): string | null {
const row = getDb()
.prepare("SELECT spark_address FROM federated_registry WHERE telegram_id = ?")
.get(telegramId) as { spark_address: string } | undefined;
return row?.spark_address ?? null;
}
function upsertLocal(telegramId: number, sparkAddress: string, publishedBy: string): void {
getDb()
.prepare(
`INSERT INTO federated_registry (telegram_id, spark_address, published_by, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (telegram_id) DO UPDATE SET
spark_address = excluded.spark_address,
published_by = excluded.published_by,
updated_at = excluded.updated_at`
)
.run(telegramId, sparkAddress, publishedBy);
}
// ─── Remote publish/fetch ─────────────────────────────────────────────────────
export async function publishToRegistry(
telegramId: number,
sparkAddress: string
): Promise<void> {
if (!config.registryUrl || !config.registryWriteKey) return;
try {
const response = await fetch(`${config.registryUrl}/entries`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.registryWriteKey}`,
},
body: JSON.stringify({
telegram_id: telegramId,
spark_address: sparkAddress,
published_by: config.botInstanceName,
}),
});
if (!response.ok) {
console.warn(`[registry] publish failed: ${response.status} ${response.statusText}`);
}
} catch (err) {
console.warn("[registry] publish error:", err);
}
// Always update local cache regardless of remote result
upsertLocal(telegramId, sparkAddress, config.botInstanceName);
}
export async function fetchFromRegistry(telegramId: number): Promise<string | null> {
// Check local cache first
const cached = lookupInRegistry(telegramId);
if (cached) return cached;
if (!config.registryUrl) return null;
try {
const headers: Record<string, string> = {};
if (config.registryReadKey) headers["Authorization"] = `Bearer ${config.registryReadKey}`;
const response = await fetch(`${config.registryUrl}/entries/${telegramId}`, { headers });
if (!response.ok) return null;
const data = (await response.json()) as { spark_address?: string; published_by?: string };
if (!data.spark_address || !isValidAddress(data.spark_address)) return null;
upsertLocal(telegramId, data.spark_address, data.published_by ?? "unknown");
return data.spark_address;
} catch (err) {
console.warn("[registry] fetch error:", err);
return null;
}
}
// ─── Identity resolution (full chain) ─────────────────────────────────────────
import { findContact } from "../db/contacts";
import { findUser } from "../db/users";
import { findClaimByIdentity } from "../db/claims";
export async function resolveRecipient(
senderUserId: number,
targetUsernameOrId: string | number | null,
replyToUserId?: number
): Promise<{ userId: number | null; address: string | null }> {
// 1. If replying to a message, try that user ID directly
if (replyToUserId != null) {
const user = findUser(replyToUserId);
if (user?.spark_address) return { userId: replyToUserId, address: user.spark_address };
const claim = findClaimByIdentity(String(replyToUserId));
if (claim?.verified_at && claim.allow_spending) {
const owner = findUser(claim.owned_by_user_id);
if (owner?.spark_address) return { userId: owner.id, address: owner.spark_address };
}
}
if (!targetUsernameOrId) return { userId: null, address: null };
const target = String(targetUsernameOrId).toLowerCase().replace(/^@/, "");
// 2. Sender's contacts/aliases
const contact = findContact(senderUserId, target);
if (contact) {
if (contact.target_address) return { userId: contact.target_user_id, address: contact.target_address };
if (contact.target_user_id) {
const user = findUser(contact.target_user_id);
if (user?.spark_address) return { userId: user.id, address: user.spark_address };
}
}
// 3. Direct user lookup by numeric ID or username
const numericId = Number(target);
if (!isNaN(numericId)) {
const user = findUser(numericId);
if (user?.spark_address) return { userId: user.id, address: user.spark_address };
} else {
// Username lookup — find user where username matches
const row = getDb()
.prepare("SELECT * FROM users WHERE LOWER(username) = ?")
.get(target) as { id: number; spark_address: string | null } | undefined;
if (row?.spark_address) return { userId: row.id, address: row.spark_address };
}
// 4. Identity claims
const claim = findClaimByIdentity(target) ?? findClaimByIdentity(`@${target}`);
if (claim?.verified_at && claim.allow_spending) {
const owner = findUser(claim.owned_by_user_id);
if (owner?.spark_address) return { userId: owner.id, address: owner.spark_address };
}
// 5. Federated registry
if (!isNaN(numericId)) {
const addr = await fetchFromRegistry(numericId);
if (addr) return { userId: numericId, address: addr };
}
return { userId: null, address: null };
}
+113
View File
@@ -0,0 +1,113 @@
import { config } from "../config";
export type SessionPolicy = "timed" | "tx-only";
export interface UnlockSession {
userId: number;
decryptedMnemonic: string;
unlockedAt: Date;
expiresAt: Date;
policy: SessionPolicy;
txRemaining?: number;
}
const sessions = new Map<number, UnlockSession>();
let sweepTimer: ReturnType<typeof setInterval> | null = null;
export function startSweep(onExpired?: (userId: number) => void): void {
if (sweepTimer) return;
sweepTimer = setInterval(() => {
const now = Date.now();
for (const [userId, session] of sessions) {
if (session.expiresAt.getTime() <= now) {
wipeSession(session);
sessions.delete(userId);
onExpired?.(userId);
}
}
}, config.sessionSweepIntervalMs);
// Prevent the timer from keeping the process alive
sweepTimer.unref?.();
}
export function stopSweep(): void {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}
export function createSession(
userId: number,
decryptedMnemonic: string,
policy: SessionPolicy,
durationSeconds: number,
txCount?: number
): UnlockSession {
// Wipe any existing session first
const existing = sessions.get(userId);
if (existing) wipeSession(existing);
const now = new Date();
const session: UnlockSession = {
userId,
decryptedMnemonic,
unlockedAt: now,
expiresAt: new Date(now.getTime() + durationSeconds * 1000),
policy,
txRemaining: policy === "tx-only" ? (txCount ?? 1) : undefined,
};
sessions.set(userId, session);
return session;
}
export function getSession(userId: number): UnlockSession | undefined {
const session = sessions.get(userId);
if (!session) return undefined;
if (session.expiresAt.getTime() <= Date.now()) {
wipeSession(session);
sessions.delete(userId);
return undefined;
}
return session;
}
export function consumeTx(userId: number): boolean {
const session = getSession(userId);
if (!session) return false;
if (session.policy === "tx-only") {
session.txRemaining = (session.txRemaining ?? 1) - 1;
if (session.txRemaining <= 0) {
wipeSession(session);
sessions.delete(userId);
}
}
return true;
}
export function destroySession(userId: number): boolean {
const session = sessions.get(userId);
if (!session) return false;
wipeSession(session);
sessions.delete(userId);
return true;
}
export function getAllActiveUserIds(): number[] {
return Array.from(sessions.keys());
}
/** Zero-fill the mnemonic string before discarding the reference. */
function wipeSession(session: UnlockSession): void {
// Overwrite the string in-place as much as JS allows
(session as { decryptedMnemonic: string }).decryptedMnemonic = "\0".repeat(
session.decryptedMnemonic.length
);
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Breez SDK Spark wrapper.
*
* One SDK instance is created per user on unlock and destroyed on lock/expiry.
* The actual SDK import is isolated here so the rest of the codebase never
* touches it directly — swap the implementation without touching callers.
*
* TODO: Replace the stub implementations with real @breeztech/breez-sdk-spark calls
* once the package API is confirmed. Each stub is marked with a TODO comment.
*/
import * as bip39 from "bip39";
import { config } from "../config";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface WalletInfo {
sparkAddress: string;
balanceSats: number;
}
export interface SendResult {
txId: string;
feeSats: number;
}
export interface ReceiveAddresses {
sparkAddress: string;
lightningInvoice: string | null; // BOLT11, null if no amount specified
onchainAddress: string;
}
export type PaymentType = "lightning" | "spark" | "onchain";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SdkInstance = any;
// ─── Active wallet instances (userId → SDK instance) ─────────────────────────
const activeWallets = new Map<number, SdkInstance>();
// ─── Mnemonic generation ──────────────────────────────────────────────────────
export function generateMnemonic(): string {
return bip39.generateMnemonic(128); // 12 words
}
export function validateMnemonic(mnemonic: string): boolean {
return bip39.validateMnemonic(mnemonic);
}
// ─── SDK lifecycle ────────────────────────────────────────────────────────────
export async function connectWallet(userId: number, mnemonic: string): Promise<WalletInfo> {
// TODO: replace with real SDK init
// const sdk = await BreezSdkSpark.connect({
// mnemonic,
// apiKey: config.breezApiKey,
// network: 'mainnet',
// });
const mockSdk: SdkInstance = {
_mnemonic: mnemonic,
_userId: userId,
// TODO: derive real spark address from SDK
sparkAddress: `spark1mock${userId}`,
// TODO: fetch real balance
balanceSats: 0,
};
activeWallets.set(userId, mockSdk);
return {
sparkAddress: mockSdk.sparkAddress,
balanceSats: mockSdk.balanceSats,
};
}
export async function getWalletInfo(userId: number): Promise<WalletInfo | null> {
const sdk = activeWallets.get(userId);
if (!sdk) return null;
// TODO: await sdk.getBalance() and sdk.getAddress()
return {
sparkAddress: sdk.sparkAddress as string,
balanceSats: sdk.balanceSats as number,
};
}
export async function sendPayment(
userId: number,
recipientAddress: string,
amountSats: number
): Promise<SendResult> {
const sdk = activeWallets.get(userId);
if (!sdk) throw new Error("Wallet not connected. /unlock first.");
// TODO: const result = await sdk.sendPayment({ destination: recipientAddress, amountSats });
// return { txId: result.txId, feeSats: result.feeSats };
const txId = `mock_tx_${Date.now()}`;
console.log(
`[wallet] MOCK sendPayment: user=${userId} to=${recipientAddress} amount=${amountSats} txId=${txId}`
);
return { txId, feeSats: 0 };
}
export async function getReceiveAddresses(
userId: number,
amountSats?: number,
description?: string
): Promise<ReceiveAddresses> {
const sdk = activeWallets.get(userId);
if (!sdk) throw new Error("Wallet not connected. Unlock first.");
// TODO: real SDK calls:
// const invoice = await sdk.receivePayment({ amountSats, description }); // BOLT11
// const onchain = await sdk.getOnchainAddress(); // Bitcoin address
const mockInvoice = amountSats
? `lnbc${amountSats}u1mock_invoice_for_user_${userId}`
: null;
return {
sparkAddress: sdk.sparkAddress as string,
lightningInvoice: mockInvoice,
onchainAddress: `bc1qmock_onchain_${userId}`,
};
}
export async function detectPaymentType(destination: string): Promise<PaymentType> {
const lower = destination.toLowerCase();
if (lower.startsWith("lnbc") || lower.startsWith("lntb") || lower.startsWith("lnurl")) {
return "lightning";
}
if (lower.startsWith("spark1") || lower.includes("@")) {
return "spark";
}
return "onchain";
}
export async function disconnectWallet(userId: number): Promise<void> {
const sdk = activeWallets.get(userId);
if (!sdk) return;
// TODO: await sdk.disconnect();
activeWallets.delete(userId);
}
export function isWalletConnected(userId: number): boolean {
return activeWallets.has(userId);
}
// ─── Spark address derivation (offline, before session) ───────────────────────
export async function deriveSparkAddress(mnemonic: string): Promise<string> {
// TODO: replace with real SDK address derivation
// const addr = await BreezSdkSpark.deriveAddress({ mnemonic, apiKey: config.breezApiKey });
const seed = await bip39.mnemonicToSeed(mnemonic);
return `spark1${seed.slice(0, 8).toString("hex")}`;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+326
View File
@@ -0,0 +1,326 @@
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);
}
},
};
}
+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>
+471
View File
@@ -0,0 +1,471 @@
/* Telegram theme variables with fallbacks for browser testing */
:root {
--bg: var(--tg-theme-bg-color, #ffffff);
--secondary-bg: var(--tg-theme-secondary-bg-color, #f4f4f5);
--text: var(--tg-theme-text-color, #111111);
--hint: var(--tg-theme-hint-color, #888888);
--link: var(--tg-theme-link-color, #2481cc);
--btn-bg: var(--tg-theme-button-color, #2481cc);
--btn-text: var(--tg-theme-button-text-color, #ffffff);
--accent: #f7931a; /* Bitcoin orange */
--radius: 12px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body.app {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100dvh;
display: flex;
flex-direction: column;
font-size: 15px;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--secondary-bg);
position: sticky;
top: 0;
z-index: 10;
}
.header-title { font-weight: 600; font-size: 16px; }
.lock-badge {
font-size: 12px;
padding: 4px 10px;
border-radius: 20px;
font-weight: 500;
}
.lock-badge.locked { background: #fee; color: #c00; }
.lock-badge.unlocked { background: #efe; color: #060; }
/* ── Content ─────────────────────────────────────────────────────────── */
.content {
flex: 1;
overflow-y: auto;
padding: 16px 16px calc(70px + var(--safe-bottom));
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
/* ── Balance card ────────────────────────────────────────────────────── */
.balance-card {
background: var(--btn-bg);
color: var(--btn-text);
border-radius: var(--radius);
padding: 24px 20px;
margin-bottom: 20px;
text-align: center;
}
.balance-label { font-size: 13px; opacity: .8; margin-bottom: 6px; }
.balance-amount {
font-size: 36px;
font-weight: 700;
letter-spacing: -1px;
}
.balance-unit { font-size: 18px; font-weight: 400; margin-left: 4px; }
.balance-btc { font-size: 13px; opacity: .7; margin-top: 4px; }
.unlock-prompt { margin-top: 16px; }
/* ── Quick actions ───────────────────────────────────────────────────── */
.quick-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.quick-btn {
background: var(--secondary-bg);
border: none;
border-radius: var(--radius);
padding: 16px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text);
cursor: pointer;
transition: opacity .15s;
}
.quick-btn:active { opacity: .7; }
.quick-icon { font-size: 22px; }
/* ── Address row ─────────────────────────────────────────────────────── */
.address-row {
background: var(--secondary-bg);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 12px;
}
.address-label { font-size: 11px; color: var(--hint); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
.address-value {
font-family: monospace;
font-size: 13px;
word-break: break-all;
cursor: pointer;
color: var(--link);
}
/* ── Session info ────────────────────────────────────────────────────── */
.session-info {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.hint { font-size: 12px; color: var(--hint); }
/* ── Buttons ─────────────────────────────────────────────────────────── */
.btn-primary {
background: var(--btn-bg);
color: var(--btn-text);
border: none;
border-radius: var(--radius);
padding: 13px 22px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity .15s;
}
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary:active:not(:disabled) { opacity: .85; }
.btn-secondary {
background: var(--secondary-bg);
color: var(--text);
border: none;
border-radius: var(--radius);
padding: 10px 18px;
font-size: 14px;
cursor: pointer;
}
.btn-sm { padding: 7px 14px; font-size: 13px; }
.btn-wide { width: 100%; margin-top: 16px; }
/* ── Form fields ─────────────────────────────────────────────────────── */
.field-label {
display: block;
font-size: 13px;
color: var(--hint);
margin: 14px 0 6px;
}
.field-input, .field-textarea {
width: 100%;
background: var(--secondary-bg);
color: var(--text);
border: none;
border-radius: var(--radius);
padding: 12px 14px;
font-size: 15px;
resize: none;
outline: none;
}
.field-textarea { line-height: 1.4; }
.type-badge {
font-size: 12px;
color: var(--hint);
margin: 6px 0 0 2px;
}
.error-msg { color: #c00; font-size: 13px; margin-top: 10px; }
.success-msg { color: #060; font-size: 13px; margin-top: 10px; }
/* ── Lock notice ─────────────────────────────────────────────────────── */
.lock-notice {
background: var(--secondary-bg);
border-radius: var(--radius);
padding: 24px;
text-align: center;
}
.lock-notice p { color: var(--hint); margin-bottom: 14px; }
/* ── Receive tabs ────────────────────────────────────────────────────── */
.receive-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.receive-tab {
flex: 1;
padding: 9px;
border: none;
border-radius: 8px;
background: var(--secondary-bg);
color: var(--hint);
font-size: 14px;
cursor: pointer;
transition: all .15s;
}
.receive-tab.active {
background: var(--btn-bg);
color: var(--btn-text);
font-weight: 600;
}
/* ── QR ──────────────────────────────────────────────────────────────── */
.qr-wrap {
display: flex;
justify-content: center;
margin: 16px 0;
background: #fff;
border-radius: var(--radius);
padding: 16px;
}
.address-copy {
font-family: monospace;
font-size: 12px;
word-break: break-all;
background: var(--secondary-bg);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
color: var(--link);
margin-bottom: 8px;
}
.invoice-text { font-size: 10px; }
/* ── Transaction list ────────────────────────────────────────────────── */
.tx-list { list-style: none; }
.tx-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--secondary-bg);
}
.tx-left { display: flex; align-items: center; gap: 12px; }
.tx-icon { font-size: 20px; width: 28px; text-align: center; }
.tx-amount { font-weight: 600; font-size: 15px; }
.tx-dest { font-size: 12px; color: var(--hint); margin-top: 2px; font-family: monospace; }
.tx-right { text-align: right; }
.tx-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: .3px;
}
.status-done { background: #efe; color: #060; }
.status-failed { background: #fee; color: #c00; }
.status-processing { background: #fef; color: #606; }
.status-expired { background: var(--secondary-bg); color: var(--hint); }
.status-awaiting_unlock { background: #ffe; color: #660; }
.tx-date { font-size: 11px; color: var(--hint); margin-top: 4px; }
/* ── Bottom tab bar ──────────────────────────────────────────────────── */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--secondary-bg);
display: flex;
padding-bottom: var(--safe-bottom);
border-top: 1px solid rgba(0,0,0,.08);
z-index: 20;
}
.tab-btn {
flex: 1;
background: none;
border: none;
padding: 10px 4px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
cursor: pointer;
color: var(--hint);
transition: color .15s;
}
.tab-btn.active { color: var(--btn-bg); }
.tab-icon { font-size: 20px; }
.tab-label { font-size: 11px; font-weight: 500; }
/* ── PIN overlay ─────────────────────────────────────────────────────── */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.pin-card {
width: 100%;
background: var(--bg);
border-radius: var(--radius) var(--radius) 0 0;
padding: 28px 24px calc(24px + var(--safe-bottom));
display: flex;
flex-direction: column;
gap: 14px;
}
.pin-title { font-size: 20px; font-weight: 700; text-align: center; }
.pin-hint { font-size: 14px; color: var(--hint); text-align: center; }
.pin-input {
width: 100%;
background: var(--secondary-bg);
color: var(--text);
border: none;
border-radius: var(--radius);
padding: 14px;
font-size: 20px;
text-align: center;
letter-spacing: 4px;
outline: none;
}
.pin-error { font-size: 13px; color: #c00; text-align: center; }
/* ── Wallet UI wrapper (hides during setup/seed) ─────────────────────── */
.wallet-ui {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
/* ── Setup screen ────────────────────────────────────────────────────── */
.setup-screen {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 20px calc(24px + var(--safe-bottom));
}
.setup-inner {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 4px;
}
.setup-icon {
font-size: 52px;
text-align: center;
margin-bottom: 8px;
}
.setup-title {
font-size: 26px;
font-weight: 800;
text-align: center;
color: var(--text);
margin-bottom: 4px;
}
.setup-sub {
font-size: 14px;
color: var(--hint);
text-align: center;
line-height: 1.5;
margin-bottom: 12px;
}
/* ── Seed reveal screen ──────────────────────────────────────────────── */
.seed-screen {
position: fixed;
inset: 0;
background: var(--bg);
z-index: 300;
overflow-y: auto;
padding: 24px 20px calc(32px + var(--safe-bottom));
display: flex;
flex-direction: column;
}
.seed-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--hint);
font-size: 15px;
}
.seed-error-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 12px;
}
.seed-error-icon { font-size: 48px; }
.seed-error-msg { color: #c00; font-size: 15px; }
.seed-content { display: flex; flex-direction: column; gap: 20px; }
.seed-title {
font-size: 24px;
font-weight: 800;
text-align: center;
color: var(--text);
}
.seed-subtitle {
font-size: 14px;
color: var(--hint);
text-align: center;
line-height: 1.5;
}
.seed-subtitle strong { color: #c00; }
.seed-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
.seed-word {
display: flex;
align-items: center;
gap: 8px;
background: var(--secondary-bg);
border-radius: 8px;
padding: 10px 12px;
}
.seed-num {
font-size: 11px;
color: var(--hint);
min-width: 18px;
text-align: right;
font-weight: 600;
}
.seed-val {
font-family: monospace;
font-size: 15px;
font-weight: 700;
color: var(--text);
letter-spacing: .5px;
}
.seed-confirm-wrap {
background: var(--secondary-bg);
border-radius: var(--radius);
padding: 16px;
}
.seed-check {
display: flex;
align-items: flex-start;
gap: 12px;
font-size: 14px;
line-height: 1.4;
cursor: pointer;
}
.seed-check input { margin-top: 2px; width: 18px; height: 18px; flex-shrink: 0; }
/* ── Toast ───────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: calc(75px + var(--safe-bottom));
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.8);
color: #fff;
border-radius: 20px;
padding: 8px 18px;
font-size: 13px;
z-index: 200;
pointer-events: none;
}