Files
gbn_ln_bot/CLAUDE.md
T
goyban e857bf4ec6 Initial commit — federated self-custodial Spark/Lightning tip bot
- grammY bot: /start, /unlock, /tip, /contact, /claim, /settings, /wallet
- AES-256-GCM mnemonic encryption with scrypt key derivation
- In-memory unlock sessions with background sweep
- Atomic claim handling (TOCTOU-safe)
- PIN rate limiting (5 attempts → 15 min lockout)
- Fastify API server + Telegram Mini App (setup, unlock, send, receive, history)
- One-time seed reveal via Mini App or auto-deleted DM message
- Federated registry client
- Docker Compose deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 13:21:43 +00:00

460 lines
17 KiB
Markdown

# CLAUDE.md — Federated Self-Custodial Spark Tip Bot
## Project overview
A self-hosted Telegram tip bot that sends satoshis over the Spark L2 / Lightning network.
Designed for small trusted circles (friends, family). Multiple independent bot instances
can share a federated user registry to form a web of trust. All funds are self-custodial —
the bot operator can never access user funds. If the bot goes offline, users recover their
funds using a standard BIP-39 seed phrase in any compatible wallet.
---
## Core principles
- **Self-custodial**: each user's mnemonic is encrypted with their own PIN/passphrase.
The server stores only the encrypted blob. Funds are never accessible to the bot operator.
- **Non-federated fallback**: if the bot goes offline permanently, users import their
BIP-39 seed into Breez, Phoenix, or any Spark-compatible wallet and keep their funds.
- **Federated by choice**: bot instances opt in to sharing a public registry of
`Telegram ID → Spark address` mappings. No funds are stored in the registry.
- **Identity portability**: users can claim multiple Telegram identities (alt accounts,
channels, group admin personas) and route all incoming payments to one wallet.
- **Container Based**: Everything should be done on docker containers, a good docker compose is what we need.
---
## Tech stack
| Layer | Choice | Reason |
|---|---|---|
| Bot framework | **grammY** (TypeScript) | Best inline query support, actively maintained, tracks latest Bot API |
| Payment SDK | **`@breeztech/breez-sdk-spark`** | Native TS, handles LNURL + Lightning addresses + Spark natively |
| Database | **SQLite** (small) or **Postgres** (if web UI wanted) | Local per-instance + shared registry |
| Runtime | **Node.js 22+** | Required by Breez SDK |
| Deployment | **Docker Compose** | Easy self-hosting, one compose file per instance |
| Session store | **In-memory Map with TTL** | Decrypted mnemonics never re-persisted to disk |
---
## Repository structure
```
/
├── src/
│ ├── bot/
│ │ ├── index.ts # grammY bot entry point
│ │ ├── commands/
│ │ │ ├── tip.ts # /tip command handler
│ │ │ ├── unlock.ts # /unlock, /lock commands
│ │ │ ├── register.ts # /start onboarding
│ │ │ ├── contact.ts # /contact add|list|remove
│ │ │ ├── claim.ts # /claim identity flow
│ │ │ └── settings.ts # /settings command
│ │ └── inline/
│ │ └── tip.ts # @mybot tip 100 [alias] inline handler
│ ├── payments/
│ │ ├── wallet.ts # Breez SDK wrapper per user
│ │ ├── session.ts # In-memory unlock session manager
│ │ └── registry.ts # Federated registry client
│ ├── db/
│ │ ├── schema.ts # DB schema definitions
│ │ ├── users.ts # User repo
│ │ ├── claims.ts # Identity claims repo
│ │ ├── contacts.ts # Alias/contacts repo
│ │ └── pending.ts # Pending transaction queue
│ └── config.ts # Env var config
├── docker-compose.yml
├── .env.example
└── CLAUDE.md # This file
```
---
## Database schema
### `users`
```sql
id BIGINT PRIMARY KEY -- Telegram User ID
username TEXT -- current @username (informational only)
encrypted_mnemonic TEXT NOT NULL -- AES-256-GCM encrypted BIP-39 mnemonic
spark_address TEXT -- user's Lightning/Spark address
unlock_duration INTEGER DEFAULT 3600 -- preferred session TTL in seconds
onboarded_at TIMESTAMP
```
### `identity_claims`
```sql
id SERIAL PRIMARY KEY
claimed_id TEXT NOT NULL -- @username, channel ID, group ID, etc.
claimed_id_type TEXT NOT NULL -- 'username' | 'channel' | 'group_admin'
owned_by_user_id BIGINT NOT NULL REFERENCES users(id)
allow_spending BOOLEAN DEFAULT TRUE -- can this identity trigger outgoing payments?
challenge_code TEXT -- null after verification
challenge_expiry TIMESTAMP
verified_at TIMESTAMP
```
### `contacts` (local aliases)
```sql
id SERIAL PRIMARY KEY
owner_user_id BIGINT NOT NULL REFERENCES users(id)
alias TEXT NOT NULL -- e.g. "satoshi"
target_user_id BIGINT -- resolved Telegram User ID
target_address TEXT -- fallback: direct Lightning address
```
### `pending_transactions`
```sql
id UUID PRIMARY KEY
initiator_user_id BIGINT NOT NULL -- who triggered /tip
recipient_user_id BIGINT -- resolved recipient (may be null if unresolved)
recipient_address TEXT -- resolved Spark/Lightning address
amount_sats INTEGER NOT NULL
status TEXT DEFAULT 'awaiting_unlock' -- awaiting_unlock | processing | done | expired | failed
initiated_via TEXT -- 'command' | 'inline'
group_chat_id BIGINT -- for posting confirmation back
created_at TIMESTAMP
expires_at TIMESTAMP -- default: now + 2 minutes
```
### `federated_registry` (shared across instances, or remote)
```sql
telegram_id BIGINT PRIMARY KEY -- Telegram User ID
spark_address TEXT NOT NULL
published_by TEXT -- which bot instance published this
updated_at TIMESTAMP
```
---
## Payment flow: bot in group (`/tip` command)
```
User replies to a message and sends: /tip 100
1. Bot reads reply_to_message.from.id → recipient Telegram ID
2. Bot resolves recipient address:
a. Check local users table
b. Check identity_claims (channel/group/alt account mappings)
c. Check federated registry
d. If not found → reply "recipient not registered, send them this link: t.me/mybot"
3. Check sender session:
a. Session unlocked → proceed immediately
b. Session locked → save pending_transaction, DM sender:
"Unlock your wallet to send 100 sats to @recipient. /unlock"
4. On unlock → process pending transaction → pay via Breez SDK
5. Bot confirms in group: "⚡ 100 sats sent to @recipient"
```
**Requirement:** Bot must be added to the group. Privacy mode OFF, or bot is admin.
---
## Payment flow: inline mode (`@mybot tip 100 [alias]`)
```
User types: @mybot tip 100 satoshi (bot NOT in group)
1. Bot receives InlineQuery with text "tip 100 satoshi"
2. Bot resolves "satoshi":
a. Check sender's contacts/aliases table → finds User ID + Spark address
b. If not found → return inline result "Unknown alias. Add with /contact add"
3. Bot returns inline result card: "⚡ Send 100 sats to Satoshi — tap to send"
4. User taps result → message posted in group
5. Bot receives ChosenInlineResult → processes payment (session rules apply as above)
6. Bot edits the message or DMs sender: "✅ Sent"
```
**Note:** Inline mode cannot detect reply context. Recipient must be specified by alias,
username, or ID in the query text. The alias system is the primary UX for this mode.
---
## Payment flow: inline mode without alias (claim button fallback)
```
User types: @mybot tip 100 (no alias, bot not in group)
1. Bot returns inline result: "⚡ Send 100 sats — tap to post, recipient claims it"
2. Message posted in group with [Claim] inline button
3. Recipient taps [Claim] → CallbackQuery carries their User ID
4. Bot resolves their address → pays
5. Bot updates message: "✅ 100 sats claimed by @recipient"
```
---
## Session unlock system
Inspired by `sudo` — authenticate once, act many times within a window.
### Commands
```
/unlock → bot asks for PIN in DM, uses stored unlock_duration preference
/unlock 4h → unlock for 4 hours regardless of preference
/unlock 1tx → unlock for one transaction only, then auto-lock
/lock → immediately wipe session
/settings unlock_duration 24h → set default unlock window
```
### Session state (in-memory only, never persisted)
```typescript
interface UnlockSession {
userId: number
decryptedMnemonic: string // wiped on expiry or /lock
unlockedAt: Date
expiresAt: Date
policy: 'timed' | 'tx-only'
txRemaining?: number // for tx-only policy
}
```
### Security rules
- Decrypted mnemonic lives **only in a Map<userId, UnlockSession>** in process memory
- A background job runs every 60 seconds, evicts expired sessions, wipes mnemonic string
- On process restart (crash, update, reboot): all sessions wiped automatically
- Bot notifies users on restart: *"Bot restarted — session cleared. /unlock to continue."*
- PIN is only ever entered in a **private DM with the bot**, never in a group
---
## Identity claim system
Users can claim multiple Telegram identities and route all incoming payments to one wallet.
The personal account must be onboarded first. Claims are extensions of a personal identity.
### Claimable identity types
#### 1. Regular username / alternate account
```
/claim @otherusername
→ Bot generates challenge: "spark-verify-a7f3k9"
→ User sends that exact text from @otherusername to the bot in DM
→ Bot verifies sender ID matches claimed username
→ Claim stored, verified
```
#### 2. Channel
```
/claim @mychannel
→ Bot generates challenge: "spark-verify-x2m8p1"
→ User posts challenge in @mychannel (as themselves or as channel)
→ User forwards that channel post to the bot
→ Bot reads forward_origin.chat.id → matches @mychannel
→ Bot reads forward_origin message text → matches challenge code
→ Claim stored, verified. User may delete the post.
```
#### 3. Anonymous group admin / group owner
```
/claim @mygroup
→ Bot generates challenge: "spark-verify-q9w2n4"
→ User sends challenge IN the group, posted AS the group identity
(Telegram: switch to "send as group/channel" mode)
→ User forwards that message to the bot
→ Bot reads forward_origin → sender is the group identity
→ Bot verifies challenge code
→ Claim stored, verified
```
For the group owner case specifically:
- Only the group owner can post "as the group"
- Forwarding a message sent as the group proves ownership
- No need to add the bot to the group permanently
### Challenge rules
- Codes are cryptographically random, min 12 characters
- Challenges expire after 10 minutes
- After verification, challenge code is deleted from DB
- A new valid claim for the same identity supersedes the old one (re-claim = transfer)
### Spending from a claimed identity
When a user posts `/tip 100` **as their channel** in a group:
```
Bot sees: message from channel identity @mychannel
→ Looks up identity_claims: @mychannel owned by User ID 111111
→ Checks allow_spending = true for this claim
→ Checks if User ID 111111's personal session is unlocked
→ Yes: send from their wallet
→ No: DM User ID 111111 (personal account):
"@mychannel tried to tip 100 sats — unlock to confirm"
```
**Constraint:** The personal account must have a DM channel open with the bot (must have
started the bot at least once). Channel/group identities cannot receive bot DMs directly.
### Identity resolution chain (lookup order)
When resolving a payment target:
```
1. contacts table (sender's local aliases) — highest priority
2. users table (direct Telegram User ID match)
3. identity_claims table (channel ID, group ID, alt username → owner)
4. federated registry (other bot instances' published addresses)
5. Not found → prompt to register
```
---
## Federated registry
### What it stores
Only public routing information. No funds, no mnemonics, no private data.
```
Telegram User ID → Spark/Lightning address
```
### How it works
- Each bot instance has a **write API key** for the registry
- On user registration or address change, the bot publishes to the registry
- Registry entries are **opt-in** — users can disable publishing in /settings
- Any bot instance can **read** the registry (read is public or requires a shared read key)
- Registry can be a shared Postgres instance, a simple REST API, or a Nostr relay
### Nostr relay option (recommended for decentralization)
- Each user's `Telegram ID → address` mapping published as a Nostr event
- Kind 0 or a custom kind, signed by the bot instance's Nostr key
- Any other instance subscribes to the relay and caches entries locally
- No central server required — censorship resistant
### Trust model
- The registry does not verify claims — it only routes payments
- If a malicious registry entry exists, the worst case is a payment goes to the wrong address
- Mitigated by: local DB takes priority over registry, users can verify their own entries
---
## Escape hatch / recovery
If the bot goes offline permanently:
1. User requests their encrypted mnemonic export via `/export` before shutdown
(or bot operator exports DB and notifies users)
2. User decrypts with their PIN
3. User imports the 12-word BIP-39 seed into:
- Breez wallet
- Phoenix wallet
- Any Spark-compatible wallet
4. Funds are fully accessible, no dependency on the bot
This must be documented clearly in the bot's onboarding `/start` message.
---
## Bot commands reference
### Onboarding
```
/start → creates wallet, explains PIN setup, shows recovery seed warning
/export → exports encrypted mnemonic (user decrypts offline with their PIN)
```
### Payments
```
/tip 100 [@user|alias] → send sats (in group, as reply or with target)
@mybot tip 100 [alias] → inline tip (anywhere, alias required for direct pay)
/balance → check wallet balance
/history → recent transactions
```
### Session
```
/unlock [duration] → unlock wallet (PIN entered in DM)
/lock → immediately lock wallet
```
### Contacts / aliases
```
/contact add <alias> <@username|userid> → save local alias
/contact list → show all aliases
/contact remove <alias> → delete alias
```
### Identity claims
```
/claim <@username|@channel|@group> → start claim flow
/unclaim <identity> → release a claim
/identities → list all claimed identities
```
### Settings
```
/settings unlock_duration <1h|4h|24h|1tx> → set default unlock window
/settings registry <on|off> → opt in/out of federated registry
/settings spending <@identity> <on|off> → allow/deny spending from claimed identity
```
---
## Security considerations
- **PIN never enters a group chat** — always collected in private DM with bot
- **Mnemonic never written to disk unencrypted** — only the AES-256-GCM blob is persisted
- **Session wipe on restart** — process crash = all sessions cleared, users re-unlock
- **Claim expiry** — challenge codes expire in 10 minutes, deleted after use
- **Claim supersession** — new valid proof overwrites old claim (supports ownership transfer)
- **Registry read-only to peers** — other instances cannot write your users' addresses
- **allow_spending flag** — claimed identities default to allow spending, user can restrict
- **Pending tx expiry** — unclaimed pending transactions expire after 2 minutes
---
## Environment variables
```env
# Telegram
BOT_TOKEN=
# Breez SDK
BREEZ_API_KEY=
# Database
DATABASE_URL=sqlite:./data/bot.db
# or: postgresql://user:pass@host:5432/botdb
# Federated registry (optional)
REGISTRY_URL=https://registry.yourdomain.com
REGISTRY_WRITE_KEY=
REGISTRY_READ_KEY=
# Session
SESSION_SWEEP_INTERVAL_MS=60000 # how often to evict expired sessions
# Bot identity
BOT_INSTANCE_NAME=mybot # shown in registry entries
```
---
## Key implementation notes
1. **One Breez SDK instance per active user session** — connect on unlock, disconnect on lock
or expiry. Do not keep SDK instances open indefinitely.
2. **grammY conversation plugin** — use it for multi-step flows (claim verification, PIN
entry, onboarding). Keeps state cleanly without manual FSM.
3. **grammY bot.on("inline_query")** — parse query text manually: `tip <amount> [alias]`.
Return results immediately (Telegram requires response within a few seconds).
4. **forward_origin field** — available in grammY as `ctx.message.forward_origin`. Check
`type === 'channel'` and read `.chat.id` for channel claim verification.
5. **Privacy mode** — if bot is in a group with privacy mode ON, it only sees messages
that start with `/`. Turn OFF for `/tip` to work as a reply in groups, or make bot admin.
6. **Telegram User ID is stable** — never use username as a primary key. Usernames can
change. Always store and resolve by numeric User ID.
7. **Breez SDK mnemonic per user** — each user gets their own mnemonic, not account
derivation from a master seed. Simpler recovery, full portability.
8. **Process restart notification** — on startup, check for users with previously unlocked
sessions (session state is in-memory, so this is everyone) and send DM:
"Bot restarted — your session was cleared. /unlock to continue."
Requires storing a `last_unlock_notified_at` flag or simply notifying on first
interaction after restart.