e857bf4ec6
- 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>
460 lines
17 KiB
Markdown
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.
|