Files
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

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

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
  • 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

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