# 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.