From e857bf4ec6975a01da559ea7b49066b4c1a9d791 Mon Sep 17 00:00:00 2001 From: goyban Date: Sun, 3 May 2026 13:21:43 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20federated=20self?= =?UTF-8?q?-custodial=20Spark/Lightning=20tip=20bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 28 +++ .gitignore | 22 ++ CLAUDE.md | 459 ++++++++++++++++++++++++++++++++++ Dockerfile | 22 ++ docker-compose.yml | 28 +++ package.json | 32 +++ src/api/auth.ts | 90 +++++++ src/api/onetime.ts | 68 +++++ src/api/ratelimit.ts | 49 ++++ src/api/routes/history.ts | 50 ++++ src/api/routes/receive.ts | 56 +++++ src/api/routes/seed.ts | 40 +++ src/api/routes/send.ts | 86 +++++++ src/api/routes/setup.ts | 73 ++++++ src/api/routes/unlock.ts | 134 ++++++++++ src/api/routes/wallet.ts | 46 ++++ src/api/server.ts | 48 ++++ src/bot/commands/claim.ts | 185 ++++++++++++++ src/bot/commands/contact.ts | 110 ++++++++ src/bot/commands/register.ts | 171 +++++++++++++ src/bot/commands/settings.ts | 164 ++++++++++++ src/bot/commands/tip.ts | 207 +++++++++++++++ src/bot/commands/unlock.ts | 156 ++++++++++++ src/bot/context.ts | 8 + src/bot/index.ts | 131 ++++++++++ src/bot/inline/tip.ts | 167 +++++++++++++ src/config.ts | 34 +++ src/db/claims.ts | 81 ++++++ src/db/contacts.ts | 47 ++++ src/db/pending.ts | 106 ++++++++ src/db/schema.ts | 86 +++++++ src/db/users.ts | 61 +++++ src/payments/crypto.ts | 53 ++++ src/payments/registry.ts | 169 +++++++++++++ src/payments/session.ts | 113 +++++++++ src/payments/wallet.ts | 161 ++++++++++++ tsconfig.json | 18 ++ webapp/app.js | 326 ++++++++++++++++++++++++ webapp/index.html | 333 +++++++++++++++++++++++++ webapp/style.css | 471 +++++++++++++++++++++++++++++++++++ 40 files changed, 4689 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/api/auth.ts create mode 100644 src/api/onetime.ts create mode 100644 src/api/ratelimit.ts create mode 100644 src/api/routes/history.ts create mode 100644 src/api/routes/receive.ts create mode 100644 src/api/routes/seed.ts create mode 100644 src/api/routes/send.ts create mode 100644 src/api/routes/setup.ts create mode 100644 src/api/routes/unlock.ts create mode 100644 src/api/routes/wallet.ts create mode 100644 src/api/server.ts create mode 100644 src/bot/commands/claim.ts create mode 100644 src/bot/commands/contact.ts create mode 100644 src/bot/commands/register.ts create mode 100644 src/bot/commands/settings.ts create mode 100644 src/bot/commands/tip.ts create mode 100644 src/bot/commands/unlock.ts create mode 100644 src/bot/context.ts create mode 100644 src/bot/index.ts create mode 100644 src/bot/inline/tip.ts create mode 100644 src/config.ts create mode 100644 src/db/claims.ts create mode 100644 src/db/contacts.ts create mode 100644 src/db/pending.ts create mode 100644 src/db/schema.ts create mode 100644 src/db/users.ts create mode 100644 src/payments/crypto.ts create mode 100644 src/payments/registry.ts create mode 100644 src/payments/session.ts create mode 100644 src/payments/wallet.ts create mode 100644 tsconfig.json create mode 100644 webapp/app.js create mode 100644 webapp/index.html create mode 100644 webapp/style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ebf0f38 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac25c33 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fcfc31a --- /dev/null +++ b/CLAUDE.md @@ -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** 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 <@username|userid> → save local alias +/contact list → show all aliases +/contact remove → delete alias +``` + +### Identity claims +``` +/claim <@username|@channel|@group> → start claim flow +/unclaim → release a claim +/identities → list all claimed identities +``` + +### Settings +``` +/settings unlock_duration <1h|4h|24h|1tx> → set default unlock window +/settings registry → opt in/out of federated registry +/settings spending <@identity> → 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 [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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24be4a9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..10c6434 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..b9cb9b9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..efd92af --- /dev/null +++ b/src/api/auth.ts @@ -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"; + } +} diff --git a/src/api/onetime.ts b/src/api/onetime.ts new file mode 100644 index 0000000..92eac0a --- /dev/null +++ b/src/api/onetime.ts @@ -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; +} + +const byToken = new Map(); +const byUserId = new Map(); // 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); +} diff --git a/src/api/ratelimit.ts b/src/api/ratelimit.ts new file mode 100644 index 0000000..7265434 --- /dev/null +++ b/src/api/ratelimit.ts @@ -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(); + +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"; + } +} diff --git a/src/api/routes/history.ts b/src/api/routes/history.ts new file mode 100644 index 0000000..12a4436 --- /dev/null +++ b/src/api/routes/history.ts @@ -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 { + 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 }; + } + ); +} diff --git a/src/api/routes/receive.ts b/src/api/routes/receive.ts new file mode 100644 index 0000000..140c295 --- /dev/null +++ b/src/api/routes/receive.ts @@ -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 { + 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, + }; + }); +} diff --git a/src/api/routes/seed.ts b/src/api/routes/seed.ts new file mode 100644 index 0000000..6c0903b --- /dev/null +++ b/src/api/routes/seed.ts @@ -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 { + /** + * GET /api/seed/reveal?token= + * + * 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(" ") }; + }); +} diff --git a/src/api/routes/send.ts b/src/api/routes/send.ts new file mode 100644 index 0000000..ebaf0a9 --- /dev/null +++ b/src/api/routes/send.ts @@ -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 { + 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 }); + } + }); +} diff --git a/src/api/routes/setup.ts b/src/api/routes/setup.ts new file mode 100644 index 0000000..54b4222 --- /dev/null +++ b/src/api/routes/setup.ts @@ -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 { + /** + * 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, + }; + }); +} diff --git a/src/api/routes/unlock.ts b/src/api/routes/unlock.ts new file mode 100644 index 0000000..31f689d --- /dev/null +++ b/src/api/routes/unlock.ts @@ -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 { + // 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, + }; + }); +} diff --git a/src/api/routes/wallet.ts b/src/api/routes/wallet.ts new file mode 100644 index 0000000..411d45a --- /dev/null +++ b/src/api/routes/wallet.ts @@ -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 { + 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, + }; + }); +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..83099c6 --- /dev/null +++ b/src/api/server.ts @@ -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 { + 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}`); +} diff --git a/src/bot/commands/claim.ts b/src/bot/commands/claim.ts new file mode 100644 index 0000000..7e166bb --- /dev/null +++ b/src/bot/commands/claim.ts @@ -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, + ctx: BotContext +): Promise { + 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): 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")); + }); +} diff --git a/src/bot/commands/contact.ts b/src/bot/commands/contact.ts new file mode 100644 index 0000000..519857d --- /dev/null +++ b/src/bot/commands/contact.ts @@ -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): 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 <@username|lightning_address>\n" + + "/contact list\n" + + "/contact remove " + ); + } + }); +} + +async function handleAdd(ctx: BotContext, ownerId: number, args: string[]): Promise { + const [alias, target] = args; + + if (!alias || !target) { + await ctx.reply("Usage: /contact add <@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 { + const contacts = listContacts(ownerId); + + if (contacts.length === 0) { + await ctx.reply("No contacts saved. Add one with /contact add <@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 { + if (!alias) { + await ctx.reply("Usage: /contact remove "); + return; + } + + const removed = deleteContact(ownerId, alias); + await ctx.reply(removed ? `✅ Contact "${alias}" removed.` : `No contact named "${alias}" found.`); +} diff --git a/src/bot/commands/register.ts b/src/bot/commands/register.ts new file mode 100644 index 0000000..d3b43ef --- /dev/null +++ b/src/bot/commands/register.ts @@ -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, + ctx: BotContext +): Promise { + 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): 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" } + ); + }); +} diff --git a/src/bot/commands/settings.ts b/src/bot/commands/settings.ts new file mode 100644 index 0000000..cfd12d3 --- /dev/null +++ b/src/bot/commands/settings.ts @@ -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 = { + "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): 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 — federated registry opt-in\n" + + `/settings spending <@identity> — 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 { + 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 { + 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 { + if (!identity || !value) { + await ctx.reply("Usage: /settings spending <@identity> "); + 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`; +} diff --git a/src/bot/commands/tip.ts b/src/bot/commands/tip.ts new file mode 100644 index 0000000..1196ded --- /dev/null +++ b/src/bot/commands/tip.ts @@ -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 { + 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): 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 [@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."); + } + }); +} + + diff --git a/src/bot/commands/unlock.ts b/src/bot/commands/unlock.ts new file mode 100644 index 0000000..1da1e55 --- /dev/null +++ b/src/bot/commands/unlock.ts @@ -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 = { s: 1, m: 60, h: 3600, d: 86400 }; + return { seconds: value * multiplier[unit], policy: "timed" }; +} + +async function unlockConversation( + conversation: Conversation, + ctx: BotContext +): Promise { + 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): 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" } + ); + }); +} diff --git a/src/bot/context.ts b/src/bot/context.ts new file mode 100644 index 0000000..a65560c --- /dev/null +++ b/src/bot/context.ts @@ -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 & ConversationFlavor; diff --git a/src/bot/index.ts b/src/bot/index.ts new file mode 100644 index 0000000..9bda0d0 --- /dev/null +++ b/src/bot/index.ts @@ -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(config.botToken); + +// ─── Middleware ─────────────────────────────────────────────────────────────── + +bot.use(session({ 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 { + 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 { + 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) +); diff --git a/src/bot/inline/tip.ts b/src/bot/inline/tip.ts new file mode 100644 index 0000000..3a27c15 --- /dev/null +++ b/src/bot/inline/tip.ts @@ -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): void { + bot.on("inline_query", async (ctx) => { + const senderId = ctx.from.id; + const text = ctx.inlineQuery.query.trim(); + + // Only handle "tip [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 [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"); + } + }); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..aed7964 --- /dev/null +++ b/src/config.ts @@ -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; diff --git a/src/db/claims.ts b/src/db/claims.ts new file mode 100644 index 0000000..9fe0443 --- /dev/null +++ b/src/db/claims.ts @@ -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); +} diff --git a/src/db/contacts.ts b/src/db/contacts.ts new file mode 100644 index 0000000..a22a2a1 --- /dev/null +++ b/src/db/contacts.ts @@ -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; +} diff --git a/src/db/pending.ts b/src/db/pending.ts new file mode 100644 index 0000000..c65f757 --- /dev/null +++ b/src/db/pending.ts @@ -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; +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..0dd0c40 --- /dev/null +++ b/src/db/schema.ts @@ -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; +} diff --git a/src/db/users.ts b/src/db/users.ts new file mode 100644 index 0000000..67aec1d --- /dev/null +++ b/src/db/users.ts @@ -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); +} diff --git a/src/payments/crypto.ts b/src/payments/crypto.ts new file mode 100644 index 0000000..a6fae17 --- /dev/null +++ b/src/payments/crypto.ts @@ -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."); + } +} diff --git a/src/payments/registry.ts b/src/payments/registry.ts new file mode 100644 index 0000000..ad9e95a --- /dev/null +++ b/src/payments/registry.ts @@ -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 { + 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 { + // Check local cache first + const cached = lookupInRegistry(telegramId); + if (cached) return cached; + + if (!config.registryUrl) return null; + + try { + const headers: Record = {}; + 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 }; +} diff --git a/src/payments/session.ts b/src/payments/session.ts new file mode 100644 index 0000000..f204570 --- /dev/null +++ b/src/payments/session.ts @@ -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(); + +let sweepTimer: ReturnType | 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 + ); +} diff --git a/src/payments/wallet.ts b/src/payments/wallet.ts new file mode 100644 index 0000000..6c3c914 --- /dev/null +++ b/src/payments/wallet.ts @@ -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(); + +// ─── 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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")}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3a29675 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..3121c7f --- /dev/null +++ b/webapp/app.js @@ -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 = "

Open this app from Telegram.

"; + return; + } + + // Seed reveal via one-time token (from bot onboarding link) + const params = new URLSearchParams(window.location.search); + const seedToken = params.get("seedToken"); + if (seedToken && window.location.hash === "#seed") { + await this.revealSeed(seedToken); + return; + } + + await this.refresh(); + }, + + // ── API ──────────────────────────────────────────────────────────────── + async api(method, path, body) { + const opts = { + method, + headers: { + "Content-Type": "application/json", + "X-Init-Data": tg.initData, + }, + }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(path, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Request failed"); + return data; + }, + + // ── Wallet state ─────────────────────────────────────────────────────── + async refresh() { + try { + const data = await this.api("GET", "/api/wallet"); + + if (!data.registered) { + this.tab = "setup"; + return; + } + + this.wallet = data; + this.receive.sparkAddress = data.sparkAddress; + this.$nextTick(() => this.renderQr("qr-spark", data.sparkAddress)); + } catch (e) { + console.error("refresh:", e.message); + } + }, + + // ── Setup — create wallet ────────────────────────────────────────────── + async createWallet() { + this.setup.error = ""; + + if (this.setup.pin.length < 6) { + this.setup.error = "PIN must be at least 6 characters."; + return; + } + if (this.setup.pin !== this.setup.confirmPin) { + this.setup.error = "PINs do not match."; + return; + } + + this.setup.creating = true; + try { + const data = await this.api("POST", "/api/setup", { pin: this.setup.pin }); + this.setup.pin = ""; + this.setup.confirmPin = ""; + + // Show seed reveal screen immediately after creation + this.seed.words = data.words; + this.seed.screen = true; + this.seed.confirmed = false; + + // Pre-load wallet state for after seed is dismissed + this.wallet.registered = true; + this.wallet.sparkAddress = data.sparkAddress; + this.receive.sparkAddress = data.sparkAddress; + } catch (e) { + this.setup.error = e.message; + } finally { + this.setup.creating = false; + } + }, + + // ── Seed reveal — via one-time URL token (from bot link) ─────────────── + async revealSeed(token) { + this.seed.screen = true; + this.seed.loading = true; + try { + const data = await this.api("GET", `/api/seed/reveal?token=${encodeURIComponent(token)}`); + this.seed.words = data.words; + } catch (e) { + this.seed.error = e.message || "Link already used or expired."; + } finally { + this.seed.loading = false; + } + }, + + async closeSeedScreen() { + this.seed.words = []; + this.seed.screen = false; + this.seed.confirmed = false; + history.replaceState(null, "", window.location.pathname); + await this.refresh(); + }, + + // ── Unlock / Lock ────────────────────────────────────────────────────── + async unlock() { + if (!this.pin) return; + this.unlocking = true; + this.pinError = ""; + try { + await this.api("POST", "/api/unlock", { pin: this.pin }); + this.showPinOverlay = false; + this.pin = ""; + await this.refresh(); + } catch (e) { + this.pinError = e.message; + } finally { + this.unlocking = false; + } + }, + + async lock() { + await this.api("POST", "/api/lock"); + await this.refresh(); + }, + + // ── Send ─────────────────────────────────────────────────────────────── + detectType() { + const d = this.send.destination.trim().toLowerCase(); + if (!d) { this.send.detectedType = ""; return; } + if (d.startsWith("lnbc") || d.startsWith("lntb") || d.startsWith("lnurl")) { + this.send.detectedType = "⚡ Lightning"; + } else if (d.startsWith("spark1") || d.includes("@")) { + this.send.detectedType = "✳️ Spark"; + } else { + this.send.detectedType = "₿ On-chain"; + } + }, + + async sendPayment() { + this.send.error = ""; + this.send.success = ""; + if (!this.send.destination.trim()) { this.send.error = "Enter a destination."; return; } + + this.send.sending = true; + try { + const body = { + destination: this.send.destination.trim(), + amountSats: this.send.amountSats ? parseInt(this.send.amountSats, 10) : undefined, + description: this.send.description || undefined, + }; + const res = await this.api("POST", "/api/send", body); + this.send.success = `✅ Sent! Fee: ${res.feeSats} sats`; + this.send.destination = ""; + this.send.amountSats = ""; + this.send.description = ""; + this.send.detectedType = ""; + await this.refresh(); + } catch (e) { + if (e.message === "wallet_locked") { + this.showPinOverlay = true; + } else { + this.send.error = e.message; + } + } finally { + this.send.sending = false; + } + }, + + // ── Receive ──────────────────────────────────────────────────────────── + async loadReceive() { + const params = new URLSearchParams(); + if (this.receive.amountSats) params.set("amount", this.receive.amountSats); + try { + const data = await this.api("GET", `/api/receive?${params}`); + this.receive.sparkAddress = data.sparkAddress; + this.receive.lightningInvoice = data.lightningInvoice; + this.receive.onchainAddress = data.onchainAddress; + this.$nextTick(() => { + this.renderQr("qr-spark", data.sparkAddress); + if (data.lightningInvoice) this.renderQr("qr-lightning", data.lightningInvoice); + if (data.onchainAddress) this.renderQr("qr-onchain", data.onchainAddress); + }); + } catch (e) { + console.error("loadReceive:", e.message); + } + }, + + // ── History ──────────────────────────────────────────────────────────── + async loadHistory() { + this.history.loading = true; + this.history.offset = 0; + try { + const data = await this.api("GET", `/api/history?limit=${this.history.limit}&offset=0`); + this.history.items = data.transactions; + } catch (e) { + console.error("loadHistory:", e.message); + } finally { + this.history.loading = false; + } + }, + + async loadMore() { + this.history.offset += this.history.limit; + try { + const data = await this.api("GET", `/api/history?limit=${this.history.limit}&offset=${this.history.offset}`); + this.history.items.push(...data.transactions); + } catch (e) { + console.error("loadMore:", e.message); + } + }, + + // ── QR ───────────────────────────────────────────────────────────────── + renderQr(canvasId, text) { + if (!text) return; + const canvas = document.getElementById(canvasId); + if (!canvas) return; + QRCode.toCanvas(canvas, text, { width: 220, margin: 2, color: { dark: "#000", light: "#fff" } }); + }, + + // ── Helpers ──────────────────────────────────────────────────────────── + satsToBtc(sats) { return (sats / 1e8).toFixed(8); }, + + formatExpiry(iso) { + if (!iso) return ""; + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + }, + + formatDate(iso) { + if (!iso) return ""; + const d = new Date(iso + "Z"); + return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + }, + + txIcon(status) { + return { done: "✅", failed: "❌", processing: "⏳", expired: "💨", awaiting_unlock: "🔒" }[status] ?? "•"; + }, + + async copyText(text) { + try { + await navigator.clipboard.writeText(text); + this.toastVisible = true; + setTimeout(() => { this.toastVisible = false; }, 1500); + } catch { + tg.showAlert("Copy: " + text); + } + }, + }; +} diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..f0d243f --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,333 @@ + + + + + + Lightning Wallet + + + + + + + + + +
+
+
+

Create Your Wallet

+

+ Choose a PIN to encrypt your wallet seed.
+ Your PIN is never stored — only you know it. +

+ + + + + + + +
+ + +
+
+ + +
+
Loading your recovery seed…
+ + + + +
+ + +
+
+

Unlock Wallet

+

Enter your PIN to unlock

+ +
+ +
+
+ + + + +
Copied!
+ + + + diff --git a/webapp/style.css b/webapp/style.css new file mode 100644 index 0000000..9b47ecb --- /dev/null +++ b/webapp/style.css @@ -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; +}