- 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>
17 KiB
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 addressmappings. 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
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
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)
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
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)
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)
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 → addressmapping 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:
- User requests their encrypted mnemonic export via
/exportbefore shutdown (or bot operator exports DB and notifies users) - User decrypts with their PIN
- User imports the 12-word BIP-39 seed into:
- Breez wallet
- Phoenix wallet
- Any Spark-compatible wallet
- 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
# 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
-
One Breez SDK instance per active user session — connect on unlock, disconnect on lock or expiry. Do not keep SDK instances open indefinitely.
-
grammY conversation plugin — use it for multi-step flows (claim verification, PIN entry, onboarding). Keeps state cleanly without manual FSM.
-
grammY bot.on("inline_query") — parse query text manually:
tip <amount> [alias]. Return results immediately (Telegram requires response within a few seconds). -
forward_origin field — available in grammY as
ctx.message.forward_origin. Checktype === 'channel'and read.chat.idfor channel claim verification. -
Privacy mode — if bot is in a group with privacy mode ON, it only sees messages that start with
/. Turn OFF for/tipto work as a reply in groups, or make bot admin. -
Telegram User ID is stable — never use username as a primary key. Usernames can change. Always store and resolve by numeric User ID.
-
Breez SDK mnemonic per user — each user gets their own mnemonic, not account derivation from a master seed. Simpler recovery, full portability.
-
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_atflag or simply notifying on first interaction after restart.