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>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# ── Telegram ──────────────────────────────────────────────────────────────────
|
||||
BOT_TOKEN=
|
||||
|
||||
# ── Breez SDK ─────────────────────────────────────────────────────────────────
|
||||
BREEZ_API_KEY=
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
# SQLite (default)
|
||||
DATABASE_URL=sqlite:./data/bot.db
|
||||
# Postgres alternative:
|
||||
# DATABASE_URL=postgresql://user:pass@localhost:5432/botdb
|
||||
|
||||
# ── Federated registry (optional) ────────────────────────────────────────────
|
||||
REGISTRY_URL=
|
||||
REGISTRY_WRITE_KEY=
|
||||
REGISTRY_READ_KEY=
|
||||
|
||||
# ── Session ───────────────────────────────────────────────────────────────────
|
||||
SESSION_SWEEP_INTERVAL_MS=60000
|
||||
|
||||
# ── Bot identity ──────────────────────────────────────────────────────────────
|
||||
BOT_INSTANCE_NAME=gbnbot
|
||||
|
||||
# ── Mini App ──────────────────────────────────────────────────────────────────
|
||||
# WEBAPP_URL must be a public HTTPS URL (Telegram requirement for Mini Apps).
|
||||
# Point your reverse proxy (nginx/Caddy) to WEBAPP_PORT.
|
||||
WEBAPP_PORT=8458
|
||||
WEBAPP_URL=https://yourbot.example.com
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment — never commit real credentials
|
||||
.env
|
||||
|
||||
# Database
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,459 @@
|
||||
# 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.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ sqlite
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY webapp ./webapp
|
||||
|
||||
RUN mkdir -p /app/data && chown -R node:node /app/data
|
||||
|
||||
USER node
|
||||
CMD ["node", "dist/bot/index.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
BREEZ_API_KEY: ${BREEZ_API_KEY}
|
||||
DATABASE_URL: sqlite:./data/bot.db
|
||||
REGISTRY_URL: ${REGISTRY_URL:-}
|
||||
REGISTRY_WRITE_KEY: ${REGISTRY_WRITE_KEY:-}
|
||||
REGISTRY_READ_KEY: ${REGISTRY_READ_KEY:-}
|
||||
SESSION_SWEEP_INTERVAL_MS: ${SESSION_SWEEP_INTERVAL_MS:-60000}
|
||||
BOT_INSTANCE_NAME: ${BOT_INSTANCE_NAME:-mybot}
|
||||
WEBAPP_PORT: ${WEBAPP_PORT:-3000}
|
||||
WEBAPP_URL: ${WEBAPP_URL:-}
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "${WEBAPP_PORT:-3000}:${WEBAPP_PORT:-3000}"
|
||||
volumes:
|
||||
- bot_data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:${WEBAPP_PORT:-3000}/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
bot_data:
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "tip-ln-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Federated self-custodial Spark/Lightning Telegram tip bot",
|
||||
"main": "dist/bot/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/bot/index.js",
|
||||
"dev": "tsx watch src/bot/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.30.0",
|
||||
"@grammyjs/conversations": "^1.0.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"bip39": "^3.1.0",
|
||||
"fastify": "^5.3.2",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/static": "^8.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/bip39": "^3.0.4",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Telegram Mini App initData validation.
|
||||
*
|
||||
* Spec: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
*
|
||||
* Algorithm:
|
||||
* secretKey = HMAC_SHA256("WebAppData", botToken)
|
||||
* checkString = sorted key=value pairs (excluding hash) joined by \n
|
||||
* signature = HMAC_SHA256(checkString, secretKey)
|
||||
* valid if signature === hash field from initData
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { config } from "../config";
|
||||
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
}
|
||||
|
||||
export interface ValidatedInitData {
|
||||
user: TelegramUser;
|
||||
auth_date: number;
|
||||
query_id?: string;
|
||||
}
|
||||
|
||||
const MAX_AGE_SECONDS = 86_400; // 24 hours
|
||||
|
||||
export function validateInitData(rawInitData: string): ValidatedInitData {
|
||||
const params = new URLSearchParams(rawInitData);
|
||||
const hash = params.get("hash");
|
||||
|
||||
if (!hash) throw new AuthError("Missing hash in initData");
|
||||
|
||||
// Validate hash is exactly 64 lowercase hex chars before byte comparison.
|
||||
// timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH on mismatched
|
||||
// buffer sizes, which would surface as an unhandled 500 instead of a clean 401.
|
||||
if (!/^[0-9a-f]{64}$/.test(hash)) throw new AuthError("Malformed hash in initData");
|
||||
|
||||
// Build the data-check string
|
||||
params.delete("hash");
|
||||
const checkString = Array.from(params.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
|
||||
const secretKey = createHmac("sha256", "WebAppData")
|
||||
.update(config.botToken)
|
||||
.digest();
|
||||
|
||||
const expectedHash = createHmac("sha256", secretKey)
|
||||
.update(checkString)
|
||||
.digest("hex");
|
||||
|
||||
if (!timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex"))) {
|
||||
throw new AuthError("Invalid initData signature");
|
||||
}
|
||||
|
||||
// Reject stale tokens
|
||||
const authDate = Number(params.get("auth_date") ?? 0);
|
||||
if (Date.now() / 1000 - authDate > MAX_AGE_SECONDS) {
|
||||
throw new AuthError("initData expired");
|
||||
}
|
||||
|
||||
const userJson = params.get("user");
|
||||
if (!userJson) throw new AuthError("Missing user in initData");
|
||||
|
||||
let user: TelegramUser;
|
||||
try {
|
||||
user = JSON.parse(userJson) as TelegramUser;
|
||||
} catch {
|
||||
throw new AuthError("Malformed user field in initData");
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
auth_date: authDate,
|
||||
query_id: params.get("query_id") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Single-use in-memory token store for mnemonic reveal.
|
||||
*
|
||||
* A token is created right after wallet generation, lives for TOKEN_TTL_MS,
|
||||
* and is destroyed the moment it is read — whichever comes first.
|
||||
* The mnemonic string is zero-filled on destruction.
|
||||
*
|
||||
* Two consumption paths share the same entry:
|
||||
* - consumeRevealToken(token, userId) → Mini App path (token from URL)
|
||||
* - consumeRevealByUserId(userId) → "show in chat" callback path
|
||||
* Whichever fires first wipes the entry so the other gets null.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface Entry {
|
||||
userId: number;
|
||||
mnemonic: string;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const byToken = new Map<string, Entry>();
|
||||
const byUserId = new Map<number, string>(); // userId → token (reverse index)
|
||||
|
||||
export function createRevealToken(userId: number, mnemonic: string): string {
|
||||
// Wipe any previous pending entry for this user
|
||||
const prev = byUserId.get(userId);
|
||||
if (prev) wipeToken(prev);
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const timer = setTimeout(() => wipeToken(token), TOKEN_TTL_MS);
|
||||
timer.unref?.();
|
||||
|
||||
byToken.set(token, { userId, mnemonic, timer });
|
||||
byUserId.set(userId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Mini App path: consume by token string embedded in the URL. */
|
||||
export function consumeRevealToken(token: string, userId: number): string | null {
|
||||
const entry = byToken.get(token);
|
||||
if (!entry || entry.userId !== userId) return null;
|
||||
const mnemonic = entry.mnemonic;
|
||||
wipeToken(token);
|
||||
return mnemonic;
|
||||
}
|
||||
|
||||
/** "Show in chat" path: consume by userId (no token needed). */
|
||||
export function consumeRevealByUserId(userId: number): string | null {
|
||||
const token = byUserId.get(userId);
|
||||
if (!token) return null;
|
||||
const entry = byToken.get(token);
|
||||
if (!entry) return null;
|
||||
const mnemonic = entry.mnemonic;
|
||||
wipeToken(token);
|
||||
return mnemonic;
|
||||
}
|
||||
|
||||
function wipeToken(token: string): void {
|
||||
const entry = byToken.get(token);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
byUserId.delete(entry.userId);
|
||||
(entry as { mnemonic: string }).mnemonic = "\0".repeat(entry.mnemonic.length);
|
||||
byToken.delete(token);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter for PIN attempts.
|
||||
*
|
||||
* After MAX_FAILURES consecutive wrong PINs the account is locked for
|
||||
* LOCKOUT_MS. The counter resets on a successful unlock.
|
||||
*/
|
||||
|
||||
const MAX_FAILURES = 5;
|
||||
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
interface Entry {
|
||||
failures: number;
|
||||
lockedUntil: number | null;
|
||||
}
|
||||
|
||||
const store = new Map<number, Entry>();
|
||||
|
||||
function entry(userId: number): Entry {
|
||||
if (!store.has(userId)) store.set(userId, { failures: 0, lockedUntil: null });
|
||||
return store.get(userId)!;
|
||||
}
|
||||
|
||||
export function checkPinRateLimit(userId: number): void {
|
||||
const e = entry(userId);
|
||||
if (e.lockedUntil && Date.now() < e.lockedUntil) {
|
||||
const secondsLeft = Math.ceil((e.lockedUntil - Date.now()) / 1000);
|
||||
throw new RateLimitError(`Too many incorrect PINs. Try again in ${secondsLeft}s.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function recordPinFailure(userId: number): void {
|
||||
const e = entry(userId);
|
||||
e.failures += 1;
|
||||
if (e.failures >= MAX_FAILURES) {
|
||||
e.lockedUntil = Date.now() + LOCKOUT_MS;
|
||||
e.failures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function recordPinSuccess(userId: number): void {
|
||||
store.delete(userId); // reset counter on success
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getDb } from "../../db/schema";
|
||||
|
||||
interface TxRow {
|
||||
id: string;
|
||||
amount_sats: number;
|
||||
status: string;
|
||||
recipient_address: string | null;
|
||||
recipient_user_id: number | null;
|
||||
initiated_via: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function historyRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
"/api/history",
|
||||
async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
if (!findUser(userId)) return reply.status(404).send({ error: "Wallet not found" });
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit ?? "20", 10), 100);
|
||||
const offset = parseInt(req.query.offset ?? "0", 10);
|
||||
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, amount_sats, status, recipient_address, recipient_user_id,
|
||||
initiated_via, created_at
|
||||
FROM pending_transactions
|
||||
WHERE initiator_user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(userId, limit, offset) as TxRow[];
|
||||
|
||||
return { transactions: rows, limit, offset };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession } from "../../payments/session";
|
||||
import { getReceiveAddresses } from "../../payments/wallet";
|
||||
|
||||
interface ReceiveQuery {
|
||||
amount?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function receiveRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Querystring: ReceiveQuery }>("/api/receive", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = findUser(userId);
|
||||
if (!user) return reply.status(404).send({ error: "Wallet not found" });
|
||||
|
||||
const session = getSession(userId);
|
||||
|
||||
// Spark address is always available (stored at registration)
|
||||
const sparkAddress = user.spark_address ?? "";
|
||||
|
||||
// Lightning invoice and on-chain address require an unlocked session
|
||||
if (!session) {
|
||||
return {
|
||||
locked: true,
|
||||
sparkAddress,
|
||||
lightningInvoice: null,
|
||||
onchainAddress: null,
|
||||
message: "Unlock your wallet to generate Lightning invoices and on-chain addresses.",
|
||||
};
|
||||
}
|
||||
|
||||
const amountSats = req.query.amount ? parseInt(req.query.amount, 10) : undefined;
|
||||
const description = req.query.description;
|
||||
|
||||
const addresses = await getReceiveAddresses(userId, amountSats, description);
|
||||
|
||||
return {
|
||||
locked: false,
|
||||
sparkAddress: addresses.sparkAddress,
|
||||
lightningInvoice: addresses.lightningInvoice,
|
||||
onchainAddress: addresses.onchainAddress,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { consumeRevealToken } from "../onetime";
|
||||
|
||||
export async function seedRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* GET /api/seed/reveal?token=<hex>
|
||||
*
|
||||
* Returns the 12-word mnemonic exactly once, then the token is wiped.
|
||||
* Requires a valid Telegram initData header so only the wallet owner
|
||||
* can consume the token — even if someone else obtains the URL.
|
||||
*/
|
||||
app.get<{ Querystring: { token?: string } }>("/api/seed/reveal", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
const token = req.query.token;
|
||||
if (!token || typeof token !== "string") {
|
||||
return reply.status(400).send({ error: "token is required" });
|
||||
}
|
||||
|
||||
const mnemonic = consumeRevealToken(token, userId);
|
||||
if (!mnemonic) {
|
||||
return reply.status(410).send({
|
||||
error: "Token expired or already used. This link can only be opened once.",
|
||||
});
|
||||
}
|
||||
|
||||
// Split into word array so the Mini App can render them in a grid
|
||||
return { words: mnemonic.split(" ") };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession } from "../../payments/session";
|
||||
import { sendPayment, detectPaymentType } from "../../payments/wallet";
|
||||
import { consumeTx } from "../../payments/session";
|
||||
import { createPending, updateStatus } from "../../db/pending";
|
||||
|
||||
interface SendBody {
|
||||
destination: string; // BOLT11 invoice | Spark address | Bitcoin address
|
||||
amountSats?: number; // required for Spark/on-chain; embedded in BOLT11
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function sendRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post<{ Body: SendBody }>("/api/send", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
if (!findUser(userId)) return reply.status(404).send({ error: "Wallet not found" });
|
||||
|
||||
const { destination, amountSats, description } = req.body ?? {};
|
||||
|
||||
if (!destination || typeof destination !== "string") {
|
||||
return reply.status(400).send({ error: "destination is required" });
|
||||
}
|
||||
|
||||
const paymentType = await detectPaymentType(destination);
|
||||
|
||||
const MAX_SATS = 21_000_000 * 100_000_000;
|
||||
if (amountSats !== undefined && (amountSats <= 0 || amountSats > MAX_SATS || !Number.isInteger(amountSats))) {
|
||||
return reply.status(400).send({ error: "amountSats must be a positive integer ≤ 21M BTC in sats" });
|
||||
}
|
||||
|
||||
if ((paymentType === "spark" || paymentType === "onchain") && !amountSats) {
|
||||
return reply.status(400).send({ error: "amountSats is required for Spark and on-chain payments" });
|
||||
}
|
||||
|
||||
const session = getSession(userId);
|
||||
if (!session) {
|
||||
return reply.status(403).send({
|
||||
error: "wallet_locked",
|
||||
message: "Unlock your wallet first.",
|
||||
});
|
||||
}
|
||||
|
||||
const sats = amountSats ?? 0;
|
||||
|
||||
// Record the transaction for history
|
||||
const txId = createPending({
|
||||
initiatorUserId: userId,
|
||||
recipientUserId: null,
|
||||
recipientAddress: destination,
|
||||
amountSats: sats,
|
||||
initiatedVia: "command", // mini app sends are treated same as commands
|
||||
groupChatId: null,
|
||||
});
|
||||
|
||||
updateStatus(txId, "processing");
|
||||
|
||||
try {
|
||||
const result = await sendPayment(userId, destination, sats);
|
||||
consumeTx(userId);
|
||||
updateStatus(txId, "done");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
txId: result.txId,
|
||||
feeSats: result.feeSats,
|
||||
paymentType,
|
||||
};
|
||||
} catch (err) {
|
||||
updateStatus(txId, "failed");
|
||||
const message = err instanceof Error ? err.message : "Payment failed";
|
||||
return reply.status(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../ratelimit";
|
||||
import { findUser, createUser } from "../../db/users";
|
||||
import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet";
|
||||
import { encryptMnemonic } from "../../payments/crypto";
|
||||
import { publishToRegistry } from "../../payments/registry";
|
||||
|
||||
const MIN_PIN_LENGTH = 6;
|
||||
|
||||
interface SetupBody {
|
||||
pin: string;
|
||||
}
|
||||
|
||||
export async function setupRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* POST /api/setup
|
||||
*
|
||||
* Creates a new wallet for the authenticated Telegram user.
|
||||
* Returns the seed words once in the response — the Mini App displays and
|
||||
* discards them. Nothing is stored in Telegram chat history.
|
||||
*/
|
||||
app.post<{ Body: SetupBody }>("/api/setup", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
let username: string | undefined;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
username = validated.user.username;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
if (findUser(userId)) {
|
||||
return reply.status(409).send({ error: "Wallet already exists." });
|
||||
}
|
||||
|
||||
const { pin } = req.body ?? {};
|
||||
if (!pin || typeof pin !== "string") {
|
||||
return reply.status(400).send({ error: "pin is required" });
|
||||
}
|
||||
if (pin.length < MIN_PIN_LENGTH) {
|
||||
return reply.status(400).send({ error: `PIN must be at least ${MIN_PIN_LENGTH} characters.` });
|
||||
}
|
||||
|
||||
try {
|
||||
checkPinRateLimit(userId);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) return reply.status(429).send({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Generate and encrypt — PIN is never stored, only the encrypted blob
|
||||
const mnemonic = generateMnemonic();
|
||||
const sparkAddress = await deriveSparkAddress(mnemonic);
|
||||
const encryptedMnemonic = encryptMnemonic(mnemonic, pin);
|
||||
|
||||
createUser(userId, username ?? null, encryptedMnemonic, sparkAddress);
|
||||
recordPinSuccess(userId);
|
||||
|
||||
await publishToRegistry(userId, sparkAddress).catch(() => null);
|
||||
|
||||
// Return seed words directly — this is an authenticated HTTPS call,
|
||||
// nothing is written to Telegram's servers.
|
||||
return {
|
||||
words: mnemonic.split(" "),
|
||||
sparkAddress,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../ratelimit";
|
||||
import { findUser } from "../../db/users";
|
||||
import { decryptMnemonic } from "../../payments/crypto";
|
||||
import { createSession, destroySession, getSession } from "../../payments/session";
|
||||
import { connectWallet, disconnectWallet } from "../../payments/wallet";
|
||||
import { findPendingForUser, updateStatus } from "../../db/pending";
|
||||
import { sendPayment } from "../../payments/wallet";
|
||||
import { consumeTx } from "../../payments/session";
|
||||
|
||||
const MAX_DURATION_SECONDS = 7 * 24 * 3600; // 7-day cap
|
||||
|
||||
interface UnlockBody {
|
||||
pin: string;
|
||||
durationSeconds?: number; // default: user's stored preference
|
||||
}
|
||||
|
||||
export async function unlockRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/unlock — verify PIN, start session, drain pending txs
|
||||
app.post<{ Body: UnlockBody }>("/api/unlock", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = findUser(userId);
|
||||
if (!user) return reply.status(404).send({ error: "Wallet not found" });
|
||||
|
||||
const { pin, durationSeconds } = req.body ?? {};
|
||||
if (!pin || typeof pin !== "string") {
|
||||
return reply.status(400).send({ error: "pin is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
checkPinRateLimit(userId);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) return reply.status(429).send({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
let mnemonic: string;
|
||||
try {
|
||||
mnemonic = decryptMnemonic(user.encrypted_mnemonic, pin);
|
||||
recordPinSuccess(userId);
|
||||
} catch {
|
||||
recordPinFailure(userId);
|
||||
return reply.status(401).send({ error: "Incorrect PIN" });
|
||||
}
|
||||
|
||||
const requestedDuration = typeof durationSeconds === "number" ? durationSeconds : null;
|
||||
const duration = Math.min(
|
||||
requestedDuration ?? (user.unlock_duration || 3600),
|
||||
MAX_DURATION_SECONDS
|
||||
);
|
||||
|
||||
await connectWallet(userId, mnemonic);
|
||||
const session = createSession(userId, mnemonic, "timed", duration);
|
||||
|
||||
// Process any queued pending transactions
|
||||
const pending = findPendingForUser(userId);
|
||||
const processed: string[] = [];
|
||||
|
||||
for (const tx of pending) {
|
||||
if (!tx.recipient_address) {
|
||||
updateStatus(tx.id, "failed");
|
||||
continue;
|
||||
}
|
||||
updateStatus(tx.id, "processing");
|
||||
try {
|
||||
await sendPayment(userId, tx.recipient_address, tx.amount_sats);
|
||||
consumeTx(userId);
|
||||
updateStatus(tx.id, "done");
|
||||
processed.push(tx.id);
|
||||
} catch {
|
||||
updateStatus(tx.id, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
expiresAt: session.expiresAt,
|
||||
pendingProcessed: processed.length,
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/lock
|
||||
app.post("/api/lock", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
const had = destroySession(userId);
|
||||
await disconnectWallet(userId);
|
||||
return { success: true, waslocked: !had };
|
||||
});
|
||||
|
||||
// GET /api/session — check current session state (no PIN needed)
|
||||
app.get("/api/session", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
userId = validated.user.id;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
const session = getSession(userId);
|
||||
if (!session) return { locked: true };
|
||||
|
||||
return {
|
||||
locked: false,
|
||||
policy: session.policy,
|
||||
expiresAt: session.expiresAt,
|
||||
txRemaining: session.txRemaining ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { validateInitData, AuthError } from "../auth";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession } from "../../payments/session";
|
||||
import { getWalletInfo } from "../../payments/wallet";
|
||||
|
||||
export async function walletRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/wallet", async (req, reply) => {
|
||||
const initData = req.headers["x-init-data"] as string | undefined;
|
||||
if (!initData) return reply.status(401).send({ error: "Missing X-Init-Data header" });
|
||||
|
||||
let tgUser: { id: number };
|
||||
try {
|
||||
const validated = validateInitData(initData);
|
||||
tgUser = validated.user;
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: err instanceof AuthError ? err.message : "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = findUser(tgUser.id);
|
||||
if (!user) return { registered: false };
|
||||
|
||||
const session = getSession(tgUser.id);
|
||||
const locked = !session;
|
||||
|
||||
let balanceSats = 0;
|
||||
let sparkAddress = user.spark_address ?? "";
|
||||
|
||||
if (!locked) {
|
||||
const info = await getWalletInfo(tgUser.id);
|
||||
if (info) {
|
||||
balanceSats = info.balanceSats;
|
||||
sparkAddress = info.sparkAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registered: true,
|
||||
locked,
|
||||
balanceSats,
|
||||
sparkAddress,
|
||||
unlockExpiresAt: session?.expiresAt ?? null,
|
||||
sessionPolicy: session?.policy ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Fastify from "fastify";
|
||||
import fastifyCors from "@fastify/cors";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import path from "path";
|
||||
import { config } from "../config";
|
||||
import { walletRoutes } from "./routes/wallet";
|
||||
import { historyRoutes } from "./routes/history";
|
||||
import { receiveRoutes } from "./routes/receive";
|
||||
import { sendRoutes } from "./routes/send";
|
||||
import { unlockRoutes } from "./routes/unlock";
|
||||
import { seedRoutes } from "./routes/seed";
|
||||
import { setupRoutes } from "./routes/setup";
|
||||
|
||||
export async function startApiServer(): Promise<void> {
|
||||
const app = Fastify({
|
||||
logger: config.nodeEnv !== "production",
|
||||
});
|
||||
|
||||
// CORS — Telegram Mini Apps are loaded in an iframe, allow all origins
|
||||
await app.register(fastifyCors, { origin: true });
|
||||
|
||||
// Serve the Mini App static files from /webapp
|
||||
await app.register(fastifyStatic, {
|
||||
root: path.join(process.cwd(), "webapp"),
|
||||
prefix: "/",
|
||||
});
|
||||
|
||||
// Health check (used by Docker)
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// API routes
|
||||
await app.register(walletRoutes);
|
||||
await app.register(historyRoutes);
|
||||
await app.register(receiveRoutes);
|
||||
await app.register(sendRoutes);
|
||||
await app.register(unlockRoutes);
|
||||
await app.register(seedRoutes);
|
||||
await app.register(setupRoutes);
|
||||
|
||||
// Global error handler
|
||||
app.setErrorHandler((err: Error & { statusCode?: number }, _req, reply) => {
|
||||
console.error("[api]", err.message);
|
||||
reply.status(err.statusCode ?? 500).send({ error: err.message });
|
||||
});
|
||||
|
||||
await app.listen({ port: config.webappPort, host: "0.0.0.0" });
|
||||
console.log(`[api] Mini App server listening on port ${config.webappPort}`);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Composer } from "grammy";
|
||||
import { type Conversation, createConversation } from "@grammyjs/conversations";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import {
|
||||
upsertChallenge,
|
||||
verifyClaim,
|
||||
deleteClaim,
|
||||
findVerifiedClaimsForUser,
|
||||
findClaimByIdentity,
|
||||
type ClaimedIdType,
|
||||
} from "../../db/claims";
|
||||
|
||||
const CHALLENGE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function generateChallengeCode(): string {
|
||||
return "spark-verify-" + randomBytes(9).toString("hex"); // 18 hex chars — 72 bits entropy
|
||||
}
|
||||
|
||||
async function claimConversation(
|
||||
conversation: Conversation<BotContext>,
|
||||
ctx: BotContext
|
||||
): Promise<void> {
|
||||
const userId = ctx.from!.id;
|
||||
const rawIdentity = (ctx.match as string | undefined)?.trim().replace(/^@/, "");
|
||||
|
||||
if (!rawIdentity) {
|
||||
await ctx.reply("Usage: /claim @username | /claim @channel | /claim @group");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!findUser(userId)) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent overwriting an in-progress challenge by a different user (DoS on claims)
|
||||
const existing = findClaimByIdentity(rawIdentity);
|
||||
if (
|
||||
existing &&
|
||||
existing.owned_by_user_id !== userId &&
|
||||
existing.challenge_expiry &&
|
||||
new Date(existing.challenge_expiry).getTime() > Date.now()
|
||||
) {
|
||||
await ctx.reply(
|
||||
`@${rawIdentity} has an active verification in progress by another user. Try again in a few minutes.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = generateChallengeCode();
|
||||
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
|
||||
|
||||
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
|
||||
|
||||
await ctx.reply(
|
||||
`To prove ownership of *@${rawIdentity}*, choose one of:\n\n` +
|
||||
`*Option A — Alt account / username:*\n` +
|
||||
`Send the code below *from @${rawIdentity}* in a DM to this bot:\n\`${code}\`\n\n` +
|
||||
`*Option B — Channel:*\n` +
|
||||
`Post the code in @${rawIdentity}, then forward that post here.\n\n` +
|
||||
`*Option C — Group (anonymous admin):*\n` +
|
||||
`Post the code in the group *as the group identity*, then forward it here.\n\n` +
|
||||
`Challenge expires in 10 minutes.`,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
// Wait for the verification message
|
||||
const verifyMsg = await conversation.waitFor(["message:text", "message:forward_origin"]);
|
||||
const msg = verifyMsg.message;
|
||||
|
||||
// ── Option A: message sent directly FROM the claimed @username ────────────
|
||||
// The message must arrive from a DIFFERENT Telegram account whose username
|
||||
// matches the claimed identity — NOT from the initiating account.
|
||||
if (msg.text === code) {
|
||||
const senderUsername = msg.from?.username?.toLowerCase();
|
||||
const senderUserId = msg.from?.id;
|
||||
|
||||
if (senderUserId !== userId && senderUsername === rawIdentity.toLowerCase()) {
|
||||
// The claimed account sent the code to us directly
|
||||
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
|
||||
verifyClaim(rawIdentity);
|
||||
await ctx.reply(`✅ @${rawIdentity} claimed successfully!`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same account sending the code — NOT proof of ownership
|
||||
await ctx.reply(
|
||||
`❌ The code must be sent *from @${rawIdentity}*, not from your own account.\n` +
|
||||
`Try /claim again if the code expired.`,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Option B / C: forwarded message from channel or group ─────────────────
|
||||
type ForwardOrigin = {
|
||||
type?: string;
|
||||
chat?: { id?: number; username?: string };
|
||||
sender_user?: { id?: number };
|
||||
};
|
||||
const origin = (msg as { forward_origin?: ForwardOrigin }).forward_origin;
|
||||
|
||||
if (!origin) {
|
||||
await ctx.reply("❌ Could not verify. The message was not sent from @" + rawIdentity + " and was not a forward. Try /claim again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardedText = (msg as { text?: string }).text?.trim() ?? "";
|
||||
if (forwardedText !== code) {
|
||||
await ctx.reply("❌ The forwarded message text does not match the challenge code. Try /claim again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (origin.type === "channel") {
|
||||
const chatUsername = origin.chat?.username?.toLowerCase();
|
||||
const chatId = String(origin.chat?.id ?? "");
|
||||
|
||||
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
|
||||
upsertChallenge(rawIdentity, "channel", userId, code, expiresAt);
|
||||
verifyClaim(rawIdentity);
|
||||
await ctx.reply(`✅ @${rawIdentity} (channel) claimed successfully!`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (origin.type === "chat") {
|
||||
const chatUsername = origin.chat?.username?.toLowerCase();
|
||||
const chatId = String(origin.chat?.id ?? "");
|
||||
|
||||
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
|
||||
upsertChallenge(rawIdentity, "group_admin", userId, code, expiresAt);
|
||||
verifyClaim(rawIdentity);
|
||||
await ctx.reply(`✅ @${rawIdentity} (group admin) claimed successfully!`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"❌ Verification failed — the forward origin did not match @" + rawIdentity + ". Try /claim again."
|
||||
);
|
||||
}
|
||||
|
||||
export function claimCommands(bot: Composer<BotContext>): void {
|
||||
bot.use(createConversation(claimConversation, "claim"));
|
||||
|
||||
bot.command("claim", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Use /claim in a private chat with me.");
|
||||
return;
|
||||
}
|
||||
await ctx.conversation.enter("claim");
|
||||
});
|
||||
|
||||
bot.command("unclaim", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const identity = ((ctx.match as string | undefined) ?? "").trim().replace(/^@/, "");
|
||||
if (!identity) {
|
||||
await ctx.reply("Usage: /unclaim @identity");
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = deleteClaim(identity, userId);
|
||||
await ctx.reply(removed ? `✅ @${identity} unclaimed.` : `No claim on @${identity} found.`);
|
||||
});
|
||||
|
||||
bot.command("identities", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const claims = findVerifiedClaimsForUser(userId);
|
||||
if (claims.length === 0) {
|
||||
await ctx.reply("No claimed identities. Use /claim @username to add one.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = claims.map(
|
||||
(c) => `• @${c.claimed_id} (${c.claimed_id_type}) — spending: ${c.allow_spending ? "on" : "off"}`
|
||||
);
|
||||
await ctx.reply(lines.join("\n"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Composer } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import { upsertContact, deleteContact, listContacts } from "../../db/contacts";
|
||||
import { getDb } from "../../db/schema";
|
||||
|
||||
export function contactCommands(bot: Composer<BotContext>): void {
|
||||
bot.command("contact", async (ctx) => {
|
||||
const senderId = ctx.from?.id;
|
||||
if (!senderId) return;
|
||||
|
||||
if (!findUser(senderId)) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
|
||||
const subcommand = args[0]?.toLowerCase();
|
||||
|
||||
switch (subcommand) {
|
||||
case "add":
|
||||
await handleAdd(ctx, senderId, args.slice(1));
|
||||
break;
|
||||
case "list":
|
||||
await handleList(ctx, senderId);
|
||||
break;
|
||||
case "remove":
|
||||
await handleRemove(ctx, senderId, args[1]);
|
||||
break;
|
||||
default:
|
||||
await ctx.reply(
|
||||
"Usage:\n" +
|
||||
"/contact add <alias> <@username|lightning_address>\n" +
|
||||
"/contact list\n" +
|
||||
"/contact remove <alias>"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAdd(ctx: BotContext, ownerId: number, args: string[]): Promise<void> {
|
||||
const [alias, target] = args;
|
||||
|
||||
if (!alias || !target) {
|
||||
await ctx.reply("Usage: /contact add <alias> <@username|lightning_address>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
||||
await ctx.reply("Alias must contain only letters, numbers, underscores, or hyphens.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanTarget = target.replace(/^@/, "");
|
||||
const numericId = Number(cleanTarget);
|
||||
|
||||
let targetUserId: number | null = null;
|
||||
let targetAddress: string | null = null;
|
||||
|
||||
if (!isNaN(numericId)) {
|
||||
targetUserId = numericId;
|
||||
const user = findUser(numericId);
|
||||
targetAddress = user?.spark_address ?? null;
|
||||
} else if (cleanTarget.includes("@") || cleanTarget.includes(".")) {
|
||||
// Looks like a Lightning address — store as-is
|
||||
targetAddress = cleanTarget;
|
||||
} else {
|
||||
// Username lookup
|
||||
const row = getDb()
|
||||
.prepare("SELECT id, spark_address FROM users WHERE LOWER(username) = ?")
|
||||
.get(cleanTarget.toLowerCase()) as { id: number; spark_address: string | null } | undefined;
|
||||
|
||||
if (row) {
|
||||
targetUserId = row.id;
|
||||
targetAddress = row.spark_address ?? null;
|
||||
} else {
|
||||
await ctx.reply(`User @${cleanTarget} is not registered with this bot.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
upsertContact(ownerId, alias, targetUserId, targetAddress);
|
||||
await ctx.reply(`✅ Contact saved: "${alias}" → ${target}`);
|
||||
}
|
||||
|
||||
async function handleList(ctx: BotContext, ownerId: number): Promise<void> {
|
||||
const contacts = listContacts(ownerId);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
await ctx.reply("No contacts saved. Add one with /contact add <alias> <@username>");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = contacts.map((c) => {
|
||||
const dest = c.target_address ?? `user #${c.target_user_id}`;
|
||||
return `• *${c.alias}* → ${dest}`;
|
||||
});
|
||||
|
||||
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||
}
|
||||
|
||||
async function handleRemove(ctx: BotContext, ownerId: number, alias?: string): Promise<void> {
|
||||
if (!alias) {
|
||||
await ctx.reply("Usage: /contact remove <alias>");
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = deleteContact(ownerId, alias);
|
||||
await ctx.reply(removed ? `✅ Contact "${alias}" removed.` : `No contact named "${alias}" found.`);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Composer, InlineKeyboard } from "grammy";
|
||||
import { type Conversation, createConversation } from "@grammyjs/conversations";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser, createUser } from "../../db/users";
|
||||
import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet";
|
||||
import { encryptMnemonic } from "../../payments/crypto";
|
||||
import { publishToRegistry } from "../../payments/registry";
|
||||
import { createRevealToken, consumeRevealByUserId } from "../../api/onetime";
|
||||
import { config } from "../../config";
|
||||
|
||||
const MIN_PIN_LENGTH = 6;
|
||||
|
||||
async function onboardingConversation(
|
||||
conversation: Conversation<BotContext>,
|
||||
ctx: BotContext
|
||||
): Promise<void> {
|
||||
const userId = ctx.from!.id;
|
||||
const chatId = ctx.chat!.id;
|
||||
const username = ctx.from?.username ?? null;
|
||||
|
||||
// ── Step 1: PIN entry via DM, deleted immediately ─────────────────────────
|
||||
const pinPrompt = await ctx.reply(
|
||||
"Welcome! I'll set up your self-custodial Lightning wallet.\n\n" +
|
||||
"Choose a PIN (min 6 characters). This encrypts your wallet seed.\n" +
|
||||
"*Send your PIN now — I'll delete it immediately after reading:*",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
const pinMsg = await conversation.waitFor("message:text");
|
||||
const pin = pinMsg.message.text.trim();
|
||||
|
||||
await Promise.allSettled([
|
||||
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
|
||||
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
|
||||
]);
|
||||
|
||||
if (pin.length < MIN_PIN_LENGTH) {
|
||||
await ctx.reply(`PIN too short (minimum ${MIN_PIN_LENGTH} characters). Try /start again.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmPrompt = await ctx.reply("Confirm your PIN:");
|
||||
const confirmMsg = await conversation.waitFor("message:text");
|
||||
const confirm = confirmMsg.message.text.trim();
|
||||
|
||||
await Promise.allSettled([
|
||||
ctx.api.deleteMessage(chatId, confirmPrompt.message_id),
|
||||
ctx.api.deleteMessage(chatId, confirmMsg.message.message_id),
|
||||
]);
|
||||
|
||||
if (pin !== confirm) {
|
||||
await ctx.reply("PINs do not match. Try /start again.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply("Generating your wallet…");
|
||||
|
||||
// ── Step 2: create wallet ──────────────────────────────────────────────────
|
||||
const mnemonic = generateMnemonic();
|
||||
const sparkAddress = await deriveSparkAddress(mnemonic);
|
||||
const encryptedMnemonic = encryptMnemonic(mnemonic, pin);
|
||||
|
||||
createUser(userId, username, encryptedMnemonic, sparkAddress);
|
||||
await publishToRegistry(userId, sparkAddress).catch(() => null);
|
||||
|
||||
// ── Step 3: offer seed reveal choice ──────────────────────────────────────
|
||||
if (config.webappUrl) {
|
||||
const token = createRevealToken(userId, mnemonic);
|
||||
const revealUrl = `${config.webappUrl}?seedToken=${token}#seed`;
|
||||
|
||||
const keyboard = new InlineKeyboard()
|
||||
.webApp("🔐 View in Mini App (recommended)", revealUrl)
|
||||
.row()
|
||||
.text("💬 Show here in chat (auto-deleted)", "seed_in_chat");
|
||||
|
||||
await ctx.reply(
|
||||
"✅ *Wallet created!*\n\n" +
|
||||
"How would you like to view your 12-word recovery seed?\n\n" +
|
||||
"• *Mini App* — shown in a secure web view, never stored in chat\n" +
|
||||
"• *Here in chat* — message deleted automatically after 60 seconds\n\n" +
|
||||
`Your Lightning address: \`${sparkAddress}\``,
|
||||
{ parse_mode: "Markdown", reply_markup: keyboard }
|
||||
);
|
||||
// Conversation ends here — seed_in_chat callback handles the other branch
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Fallback: no Mini App configured — always show in chat ────────────────
|
||||
const seedMsg = await ctx.reply(
|
||||
"✅ *Wallet created!*\n\n" +
|
||||
"Your recovery seed (12 words):\n" +
|
||||
`\`${mnemonic}\`\n\n` +
|
||||
"📋 *Write this down offline now.* This message deletes in 60 seconds.\n\n" +
|
||||
`Your Lightning address: \`${sparkAddress}\`\n\n` +
|
||||
"/unlock — unlock wallet to send\n/balance — check balance",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
ctx.api.deleteMessage(chatId, seedMsg.message_id).catch(() => null);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
export function registerCommands(bot: Composer<BotContext>): void {
|
||||
bot.use(createConversation(onboardingConversation, "onboarding"));
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Please DM me to set up your wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = ctx.from!.id;
|
||||
if (findUser(userId)) {
|
||||
await ctx.reply(
|
||||
"You already have a wallet!\n\n" +
|
||||
"/unlock — unlock wallet\n/balance — check balance\n" +
|
||||
"/tip — send sats\n/wallet — open Mini App\n/export — export encrypted seed backup"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.conversation.enter("onboarding");
|
||||
});
|
||||
|
||||
// "Show here in chat" branch of the seed reveal choice
|
||||
bot.callbackQuery("seed_in_chat", async (ctx) => {
|
||||
const userId = ctx.from.id;
|
||||
const mnemonic = consumeRevealByUserId(userId);
|
||||
|
||||
await ctx.answerCallbackQuery();
|
||||
|
||||
if (!mnemonic || mnemonic.startsWith("\0")) {
|
||||
await ctx.reply("This seed has already been revealed or expired. Use /export to get your encrypted backup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = findUser(userId);
|
||||
const seedMsg = await ctx.reply(
|
||||
"Your recovery seed (12 words):\n" +
|
||||
`\`${mnemonic}\`\n\n` +
|
||||
"📋 *Write this down offline now.* This message deletes in 60 seconds.",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
ctx.api.deleteMessage(ctx.chat!.id, seedMsg.message_id).catch(() => null);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
bot.command("export", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Use /export in a private chat with me.");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = findUser(ctx.from!.id);
|
||||
if (!user) {
|
||||
await ctx.reply("No wallet found. Use /start to create one.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"🔐 *Encrypted wallet backup*\n\n" +
|
||||
"```\n" + user.encrypted_mnemonic + "\n```\n\n" +
|
||||
"Decrypt offline with your PIN using AES-256-GCM + scrypt.\n" +
|
||||
"Format: `salt(32B) + iv(12B) + authTag(16B) + ciphertext` — all base64.",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Composer } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser, updateUnlockDuration } from "../../db/users";
|
||||
import { setAllowSpending, findClaimByIdentity } from "../../db/claims";
|
||||
import { publishToRegistry } from "../../payments/registry";
|
||||
import { getDb } from "../../db/schema";
|
||||
|
||||
const DURATION_MAP: Record<string, number> = {
|
||||
"15m": 15 * 60,
|
||||
"30m": 30 * 60,
|
||||
"1h": 3600,
|
||||
"4h": 4 * 3600,
|
||||
"8h": 8 * 3600,
|
||||
"24h": 24 * 3600,
|
||||
"48h": 48 * 3600,
|
||||
};
|
||||
|
||||
export function settingsCommands(bot: Composer<BotContext>): void {
|
||||
bot.command("settings", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = findUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
|
||||
const key = args[0]?.toLowerCase();
|
||||
|
||||
if (!key) {
|
||||
await ctx.reply(
|
||||
"Settings:\n" +
|
||||
"/settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx> — default unlock window\n" +
|
||||
"/settings registry <on|off> — federated registry opt-in\n" +
|
||||
`/settings spending <@identity> <on|off> — allow/deny spending from identity\n\n` +
|
||||
`Current unlock duration: ${formatDuration(user.unlock_duration)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "unlock_duration":
|
||||
await handleUnlockDuration(ctx, userId, args[1]);
|
||||
break;
|
||||
case "registry":
|
||||
await handleRegistry(ctx, userId, user, args[1]);
|
||||
break;
|
||||
case "spending":
|
||||
await handleSpending(ctx, userId, args[1], args[2]);
|
||||
break;
|
||||
default:
|
||||
await ctx.reply(`Unknown setting: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("history", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, amount_sats, status, recipient_address, created_at
|
||||
FROM pending_transactions
|
||||
WHERE initiator_user_id = ?
|
||||
ORDER BY created_at DESC LIMIT 10`
|
||||
)
|
||||
.all(userId) as {
|
||||
id: string;
|
||||
amount_sats: number;
|
||||
status: string;
|
||||
recipient_address: string | null;
|
||||
created_at: string;
|
||||
}[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await ctx.reply("No transaction history yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r) => {
|
||||
const statusIcon = r.status === "done" ? "✅" : r.status === "failed" ? "❌" : "⏳";
|
||||
const dest = r.recipient_address?.slice(0, 20) ?? "unknown";
|
||||
return `${statusIcon} ${r.amount_sats} sats → ${dest}… (${r.created_at.slice(0, 10)})`;
|
||||
});
|
||||
|
||||
await ctx.reply(lines.join("\n"));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUnlockDuration(ctx: BotContext, userId: number, value?: string): Promise<void> {
|
||||
if (!value) {
|
||||
await ctx.reply("Usage: /settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx>");
|
||||
return;
|
||||
}
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
|
||||
if (lower === "1tx") {
|
||||
// Store 0 as a sentinel for tx-only mode preference
|
||||
updateUnlockDuration(userId, 0);
|
||||
await ctx.reply("Default unlock mode set to: 1 transaction.");
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = DURATION_MAP[lower];
|
||||
if (!seconds) {
|
||||
await ctx.reply(`Unknown duration. Choose one of: ${Object.keys(DURATION_MAP).join(", ")}, 1tx`);
|
||||
return;
|
||||
}
|
||||
|
||||
updateUnlockDuration(userId, seconds);
|
||||
await ctx.reply(`Default unlock duration set to ${value}.`);
|
||||
}
|
||||
|
||||
async function handleRegistry(
|
||||
ctx: BotContext,
|
||||
userId: number,
|
||||
user: { spark_address: string | null },
|
||||
value?: string
|
||||
): Promise<void> {
|
||||
if (value === "on") {
|
||||
if (user.spark_address) {
|
||||
await publishToRegistry(userId, user.spark_address);
|
||||
}
|
||||
await ctx.reply("✅ Federated registry publishing enabled.");
|
||||
} else if (value === "off") {
|
||||
// TODO: send a delete request to the remote registry if supported
|
||||
await ctx.reply("Registry publishing disabled. Your entry remains in remote caches until they expire.");
|
||||
} else {
|
||||
await ctx.reply("Usage: /settings registry on|off");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSpending(
|
||||
ctx: BotContext,
|
||||
userId: number,
|
||||
identity?: string,
|
||||
value?: string
|
||||
): Promise<void> {
|
||||
if (!identity || !value) {
|
||||
await ctx.reply("Usage: /settings spending <@identity> <on|off>");
|
||||
return;
|
||||
}
|
||||
|
||||
const clean = identity.replace(/^@/, "");
|
||||
const claim = findClaimByIdentity(clean);
|
||||
|
||||
if (!claim || claim.owned_by_user_id !== userId || !claim.verified_at) {
|
||||
await ctx.reply(`No verified claim on @${clean}. Use /claim first.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allow = value.toLowerCase() === "on";
|
||||
setAllowSpending(clean, userId, allow);
|
||||
await ctx.reply(`Spending from @${clean}: ${allow ? "enabled" : "disabled"}.`);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return "1tx";
|
||||
if (seconds < 3600) return `${seconds / 60}m`;
|
||||
return `${seconds / 3600}h`;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Composer } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession, consumeTx } from "../../payments/session";
|
||||
import { sendPayment } from "../../payments/wallet";
|
||||
import { resolveRecipient } from "../../payments/registry";
|
||||
import { createPending, findPending, updateStatus, claimForRecipient, type PendingTransaction } from "../../db/pending";
|
||||
|
||||
// ─── Core payment execution ────────────────────────────────────────────────────
|
||||
|
||||
export async function executePay(
|
||||
userId: number,
|
||||
recipientAddress: string,
|
||||
amountSats: number
|
||||
): Promise<{ txId: string; feeSats: number }> {
|
||||
const session = getSession(userId);
|
||||
if (!session) throw new Error("wallet_locked");
|
||||
|
||||
const result = await sendPayment(userId, recipientAddress, amountSats);
|
||||
consumeTx(userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Process a queued pending transaction ─────────────────────────────────────
|
||||
|
||||
export async function processPending(ctx: BotContext, tx: PendingTransaction): Promise<void> {
|
||||
if (!tx.recipient_address) {
|
||||
updateStatus(tx.id, "failed");
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(tx.id, "processing");
|
||||
|
||||
try {
|
||||
await executePay(tx.initiator_user_id, tx.recipient_address, tx.amount_sats);
|
||||
updateStatus(tx.id, "done");
|
||||
|
||||
// Confirm back in the group if we know which chat
|
||||
if (tx.group_chat_id) {
|
||||
try {
|
||||
const recipientLabel =
|
||||
tx.recipient_user_id ? `user #${tx.recipient_user_id}` : tx.recipient_address;
|
||||
await ctx.api.sendMessage(
|
||||
tx.group_chat_id,
|
||||
`⚡ ${tx.amount_sats} sats sent to ${recipientLabel}`
|
||||
);
|
||||
} catch {
|
||||
// group message is best-effort
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
updateStatus(tx.id, "failed");
|
||||
console.error("[tip] processPending failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /tip command ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function tipCommands(bot: Composer<BotContext>): void {
|
||||
bot.command("tip", async (ctx) => {
|
||||
const senderId = ctx.from?.id;
|
||||
if (!senderId) return;
|
||||
|
||||
const sender = findUser(senderId);
|
||||
if (!sender) {
|
||||
await ctx.reply("You need a wallet first. DM me /start to set one up.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse amount (and optional target) from command args
|
||||
const args = (ctx.match as string | undefined)?.trim().split(/\s+/) ?? [];
|
||||
const amountSats = args[0] ? parseInt(args[0], 10) : NaN;
|
||||
|
||||
const MAX_SATS = 21_000_000 * 100_000_000; // 21M BTC in sats
|
||||
if (isNaN(amountSats) || amountSats <= 0 || amountSats > MAX_SATS) {
|
||||
await ctx.reply("Usage: /tip <amount_sats> [@user|alias]\nExample: /tip 100");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetArg = args[1] ?? null;
|
||||
const replyToUserId = ctx.message?.reply_to_message?.from?.id;
|
||||
const groupChatId = ctx.chat?.type !== "private" ? ctx.chat?.id ?? null : null;
|
||||
|
||||
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
|
||||
senderId,
|
||||
targetArg,
|
||||
replyToUserId
|
||||
);
|
||||
|
||||
if (!recipientAddress) {
|
||||
await ctx.reply(
|
||||
"Recipient not found or not registered. Share this link with them: t.me/" +
|
||||
ctx.me.username
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = getSession(senderId);
|
||||
|
||||
if (!session) {
|
||||
// Queue the transaction and ask user to unlock
|
||||
const txId = createPending({
|
||||
initiatorUserId: senderId,
|
||||
recipientUserId: recipientUserId,
|
||||
recipientAddress,
|
||||
amountSats,
|
||||
initiatedVia: "command",
|
||||
groupChatId,
|
||||
});
|
||||
|
||||
await ctx.reply(
|
||||
`Wallet is locked. DM me /unlock to send ${amountSats} sats.\nTransaction queued (expires in 2 min, ref: \`${txId.slice(0, 8)}\`).`,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
// DM the sender if we're in a group
|
||||
if (groupChatId) {
|
||||
try {
|
||||
await ctx.api.sendMessage(
|
||||
senderId,
|
||||
`You have a pending tip of ${amountSats} sats. /unlock to confirm.`
|
||||
);
|
||||
} catch {
|
||||
// DM might be blocked — the group message already told them
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wallet unlocked — send immediately
|
||||
try {
|
||||
await executePay(senderId, recipientAddress, amountSats);
|
||||
const recipientLabel = replyToUserId
|
||||
? `@${ctx.message?.reply_to_message?.from?.username ?? replyToUserId}`
|
||||
: targetArg ?? recipientAddress;
|
||||
|
||||
await ctx.reply(`⚡ ${amountSats} sats sent to ${recipientLabel}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "unknown error";
|
||||
await ctx.reply(`❌ Payment failed: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Callback handler for claim button (inline mode without alias)
|
||||
bot.callbackQuery(/^claim:(.+)$/, async (ctx) => {
|
||||
const txId = ctx.match[1];
|
||||
const claimerId = ctx.from.id;
|
||||
|
||||
// Reject self-claims: sender should not claim their own tip
|
||||
const txCheck = findPending(txId);
|
||||
if (txCheck?.initiator_user_id === claimerId) {
|
||||
await ctx.answerCallbackQuery("You cannot claim your own tip.");
|
||||
return;
|
||||
}
|
||||
|
||||
const claimer = findUser(claimerId);
|
||||
if (!claimer?.spark_address) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: "You need a wallet to claim this. DM me /start to set one up.",
|
||||
show_alert: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic claim: only one concurrent caller can succeed (TOCTOU-safe)
|
||||
const won = claimForRecipient(txId, claimerId, claimer.spark_address);
|
||||
if (!won) {
|
||||
await ctx.answerCallbackQuery("This tip has already been claimed or expired.");
|
||||
return;
|
||||
}
|
||||
|
||||
// We now own the tx exclusively (status = 'processing' in DB)
|
||||
const tx = findPending(txId)!;
|
||||
const session = getSession(tx.initiator_user_id);
|
||||
|
||||
if (!session) {
|
||||
// Revert to awaiting_unlock so the sender can still pay on /unlock
|
||||
updateStatus(txId, "awaiting_unlock");
|
||||
await ctx.answerCallbackQuery("Sender needs to unlock their wallet first.");
|
||||
|
||||
try {
|
||||
await ctx.api.sendMessage(
|
||||
tx.initiator_user_id,
|
||||
`@${ctx.from.username ?? claimerId} wants to claim your ${tx.amount_sats} sat tip. /unlock to send.`
|
||||
);
|
||||
} catch {
|
||||
// best-effort DM
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await executePay(tx.initiator_user_id, claimer.spark_address, tx.amount_sats);
|
||||
updateStatus(txId, "done");
|
||||
|
||||
await ctx.editMessageText(
|
||||
`✅ ${tx.amount_sats} sats claimed by @${ctx.from.username ?? claimerId}`
|
||||
);
|
||||
await ctx.answerCallbackQuery("Payment sent!");
|
||||
} catch {
|
||||
updateStatus(txId, "failed");
|
||||
await ctx.answerCallbackQuery("Payment failed. Try again later.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Composer } from "grammy";
|
||||
import { type Conversation, createConversation } from "@grammyjs/conversations";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser, updateUnlockDuration } from "../../db/users";
|
||||
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../../api/ratelimit";
|
||||
import { getWalletInfo } from "../../payments/wallet";
|
||||
import { decryptMnemonic } from "../../payments/crypto";
|
||||
import { createSession, destroySession, getSession } from "../../payments/session";
|
||||
import { connectWallet, disconnectWallet } from "../../payments/wallet";
|
||||
import { findPendingForUser } from "../../db/pending";
|
||||
import { processPending } from "./tip";
|
||||
|
||||
function parseDuration(raw: string): { seconds: number; policy: "timed" | "tx-only"; txCount?: number } | null {
|
||||
const txMatch = raw.match(/^(\d+)?tx$/i);
|
||||
if (txMatch) {
|
||||
const count = txMatch[1] ? parseInt(txMatch[1], 10) : 1;
|
||||
return { seconds: 86400, policy: "tx-only", txCount: count };
|
||||
}
|
||||
|
||||
const timeMatch = raw.match(/^(\d+)(s|m|h|d)$/i);
|
||||
if (!timeMatch) return null;
|
||||
|
||||
const value = parseInt(timeMatch[1], 10);
|
||||
const unit = timeMatch[2].toLowerCase();
|
||||
const multiplier: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
||||
return { seconds: value * multiplier[unit], policy: "timed" };
|
||||
}
|
||||
|
||||
async function unlockConversation(
|
||||
conversation: Conversation<BotContext>,
|
||||
ctx: BotContext
|
||||
): Promise<void> {
|
||||
const userId = ctx.from!.id;
|
||||
const user = findUser(userId);
|
||||
|
||||
if (!user) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ctx.match as string | undefined;
|
||||
let durationSeconds = user.unlock_duration;
|
||||
let policy: "timed" | "tx-only" = "timed";
|
||||
let txCount: number | undefined;
|
||||
|
||||
if (args?.trim()) {
|
||||
const parsed = parseDuration(args.trim());
|
||||
if (!parsed) {
|
||||
await ctx.reply("Unknown duration format. Examples: /unlock 4h | /unlock 1tx | /unlock 30m");
|
||||
return;
|
||||
}
|
||||
({ seconds: durationSeconds, policy } = parsed);
|
||||
txCount = parsed.txCount;
|
||||
}
|
||||
|
||||
try {
|
||||
checkPinRateLimit(userId);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) {
|
||||
await ctx.reply(`🚫 ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const chatId = ctx.chat!.id;
|
||||
const pinPrompt = await ctx.reply(
|
||||
"🔐 Enter your PIN — I'll delete it immediately after reading:"
|
||||
);
|
||||
|
||||
const pinMsg = await conversation.waitFor("message:text");
|
||||
const pin = pinMsg.message.text.trim();
|
||||
|
||||
// Delete both the prompt and the user's PIN before doing anything else
|
||||
await Promise.allSettled([
|
||||
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
|
||||
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
|
||||
]);
|
||||
|
||||
let mnemonic: string;
|
||||
try {
|
||||
mnemonic = decryptMnemonic(user.encrypted_mnemonic, pin);
|
||||
recordPinSuccess(userId);
|
||||
} catch {
|
||||
recordPinFailure(userId);
|
||||
await ctx.reply("❌ Incorrect PIN.");
|
||||
return;
|
||||
}
|
||||
|
||||
await connectWallet(userId, mnemonic);
|
||||
createSession(userId, mnemonic, policy, durationSeconds, txCount);
|
||||
|
||||
const label =
|
||||
policy === "tx-only"
|
||||
? `${txCount ?? 1} transaction(s)`
|
||||
: durationSeconds >= 3600
|
||||
? `${durationSeconds / 3600}h`
|
||||
: `${durationSeconds / 60}m`;
|
||||
|
||||
await ctx.reply(`✅ Wallet unlocked for ${label}.`);
|
||||
|
||||
// Process any pending transactions
|
||||
const pending = findPendingForUser(userId);
|
||||
if (pending.length > 0) {
|
||||
await ctx.reply(`Processing ${pending.length} pending transaction(s)...`);
|
||||
for (const tx of pending) {
|
||||
await processPending(ctx, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unlockCommands(bot: Composer<BotContext>): void {
|
||||
bot.use(createConversation(unlockConversation, "unlock"));
|
||||
|
||||
bot.command("unlock", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Please use /unlock in a private chat with me (for PIN security).");
|
||||
return;
|
||||
}
|
||||
await ctx.conversation.enter("unlock");
|
||||
});
|
||||
|
||||
bot.command("lock", async (ctx) => {
|
||||
const userId = ctx.from!.id;
|
||||
const had = destroySession(userId);
|
||||
await disconnectWallet(userId);
|
||||
|
||||
if (had) {
|
||||
await ctx.reply("🔒 Wallet locked.");
|
||||
} else {
|
||||
await ctx.reply("Wallet is already locked.");
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("balance", async (ctx) => {
|
||||
const userId = ctx.from!.id;
|
||||
const session = getSession(userId);
|
||||
|
||||
if (!session) {
|
||||
await ctx.reply("Wallet is locked. Use /unlock first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await getWalletInfo(userId);
|
||||
|
||||
if (!info) {
|
||||
await ctx.reply("Could not fetch balance. Try /unlock again.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
`⚡ Balance: *${info.balanceSats} sats*\nAddress: \`${info.sparkAddress}\``,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Context, SessionFlavor } from "grammy";
|
||||
import { ConversationFlavor } from "@grammyjs/conversations";
|
||||
|
||||
export interface SessionData {
|
||||
// intentionally empty — conversations plugin manages its own state
|
||||
}
|
||||
|
||||
export type BotContext = Context & SessionFlavor<SessionData> & ConversationFlavor;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Bot, InlineKeyboard, session } from "grammy";
|
||||
import { conversations } from "@grammyjs/conversations";
|
||||
import { run } from "@grammyjs/runner";
|
||||
import { config } from "../config";
|
||||
import { getDb } from "../db/schema";
|
||||
import { getAllUserIds, findUser, updateUsername } from "../db/users";
|
||||
import { expireStale } from "../db/pending";
|
||||
import { startSweep, stopSweep, destroySession, getAllActiveUserIds } from "../payments/session";
|
||||
import { disconnectWallet } from "../payments/wallet";
|
||||
import { startApiServer } from "../api/server";
|
||||
import type { BotContext, SessionData } from "./context";
|
||||
import { registerCommands } from "./commands/register";
|
||||
import { unlockCommands } from "./commands/unlock";
|
||||
import { tipCommands } from "./commands/tip";
|
||||
import { contactCommands } from "./commands/contact";
|
||||
import { claimCommands } from "./commands/claim";
|
||||
import { settingsCommands } from "./commands/settings";
|
||||
import { inlineTipHandler } from "./inline/tip";
|
||||
|
||||
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
getDb();
|
||||
|
||||
const bot = new Bot<BotContext>(config.botToken);
|
||||
|
||||
// ─── Middleware ───────────────────────────────────────────────────────────────
|
||||
|
||||
bot.use(session<SessionData, BotContext>({ initial: () => ({}) }));
|
||||
bot.use(conversations());
|
||||
|
||||
// Keep username up-to-date on every interaction
|
||||
bot.use(async (ctx, next) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (userId) {
|
||||
const user = findUser(userId);
|
||||
if (user && ctx.from?.username !== user.username) {
|
||||
updateUsername(userId, ctx.from?.username ?? null);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerCommands(bot);
|
||||
unlockCommands(bot);
|
||||
tipCommands(bot);
|
||||
contactCommands(bot);
|
||||
claimCommands(bot);
|
||||
settingsCommands(bot);
|
||||
inlineTipHandler(bot);
|
||||
|
||||
// /wallet — open the Mini App
|
||||
bot.command("wallet", async (ctx) => {
|
||||
const webappUrl = config.webappUrl;
|
||||
if (!webappUrl) {
|
||||
await ctx.reply("Mini App is not configured. Set WEBAPP_URL in the bot environment.");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyboard = new InlineKeyboard().webApp("Open Wallet", webappUrl);
|
||||
await ctx.reply("Tap to open your wallet:", { reply_markup: keyboard });
|
||||
});
|
||||
|
||||
// ─── Error handler ────────────────────────────────────────────────────────────
|
||||
|
||||
bot.catch((err) => {
|
||||
console.error("[bot] Unhandled error:", err.message);
|
||||
console.error(err.error);
|
||||
});
|
||||
|
||||
// ─── Session sweep ────────────────────────────────────────────────────────────
|
||||
|
||||
startSweep(async (userId) => {
|
||||
await disconnectWallet(userId).catch(() => null);
|
||||
console.log(`[session] Expired for user ${userId}`);
|
||||
});
|
||||
|
||||
// ─── Stale pending transaction cleanup ────────────────────────────────────────
|
||||
|
||||
setInterval(() => {
|
||||
const count = expireStale();
|
||||
if (count > 0) console.log(`[pending] Expired ${count} stale transaction(s)`);
|
||||
}, 60_000).unref?.();
|
||||
|
||||
// ─── Startup notification ─────────────────────────────────────────────────────
|
||||
|
||||
async function notifyUsersOnRestart(): Promise<void> {
|
||||
const userIds = getAllUserIds();
|
||||
const message = "🔄 Bot restarted — your wallet session was cleared. /unlock to continue.";
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await bot.api.sendMessage(userId, message);
|
||||
} catch {
|
||||
// User may have blocked the bot or not started it yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Graceful shutdown ────────────────────────────────────────────────────────
|
||||
|
||||
async function shutdown(runner: { stop(): void }): Promise<void> {
|
||||
console.log("[bot] Shutting down...");
|
||||
runner.stop();
|
||||
stopSweep();
|
||||
for (const userId of getAllActiveUserIds()) {
|
||||
destroySession(userId);
|
||||
await disconnectWallet(userId).catch(() => null);
|
||||
}
|
||||
getDb().close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const runner = run(bot);
|
||||
|
||||
process.once("SIGINT", () => shutdown(runner));
|
||||
process.once("SIGTERM", () => shutdown(runner));
|
||||
|
||||
// Start the Mini App API server alongside the bot
|
||||
startApiServer().catch((err) => {
|
||||
console.error("[api] Failed to start:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
console.log(`[bot] ${config.botInstanceName} started.`);
|
||||
|
||||
notifyUsersOnRestart().catch((err) =>
|
||||
console.warn("[bot] Restart notification error:", err.message)
|
||||
);
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Composer, InlineKeyboard } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession } from "../../payments/session";
|
||||
import { resolveRecipient } from "../../payments/registry";
|
||||
import { createPending, findPending, updateStatus } from "../../db/pending";
|
||||
import { executePay } from "../commands/tip";
|
||||
|
||||
// ─── Inline query: @mybot tip 100 [alias] ────────────────────────────────────
|
||||
|
||||
export function inlineTipHandler(bot: Composer<BotContext>): void {
|
||||
bot.on("inline_query", async (ctx) => {
|
||||
const senderId = ctx.from.id;
|
||||
const text = ctx.inlineQuery.query.trim();
|
||||
|
||||
// Only handle "tip <amount> [alias]" pattern
|
||||
const match = text.match(/^tip\s+(\d+)(?:\s+(\S+))?$/i);
|
||||
|
||||
if (!match) {
|
||||
await ctx.answerInlineQuery([], {
|
||||
cache_time: 0,
|
||||
button: { text: "How to use: tip <amount> [alias]", start_parameter: "help" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const amountSats = parseInt(match[1], 10);
|
||||
const alias = match[2] ?? null;
|
||||
|
||||
if (!findUser(senderId)) {
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: "not_registered",
|
||||
title: "Wallet not set up",
|
||||
description: "DM me /start to create your wallet",
|
||||
input_message_content: { message_text: "I need to set up my wallet first." },
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!alias) {
|
||||
// No alias — post a "claim" message with a button
|
||||
const txId = createPending({
|
||||
initiatorUserId: senderId,
|
||||
recipientUserId: null,
|
||||
recipientAddress: null,
|
||||
amountSats,
|
||||
initiatedVia: "inline",
|
||||
groupChatId: null, // resolved in chosen_inline_result
|
||||
});
|
||||
|
||||
const keyboard = new InlineKeyboard().text(`⚡ Claim ${amountSats} sats`, `claim:${txId}`);
|
||||
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: `tip_claim_${txId}`,
|
||||
title: `⚡ Send ${amountSats} sats — anyone can claim`,
|
||||
description: "Tap to post. First person to tap Claim receives the payment.",
|
||||
input_message_content: {
|
||||
message_text: `⚡ ${amountSats} sats up for grabs — tap to claim!`,
|
||||
},
|
||||
reply_markup: keyboard,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve alias to an address
|
||||
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
|
||||
senderId,
|
||||
alias
|
||||
);
|
||||
|
||||
if (!recipientAddress) {
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: "alias_not_found",
|
||||
title: `Unknown alias: ${alias}`,
|
||||
description: "Add contacts with /contact add",
|
||||
input_message_content: {
|
||||
message_text: `Could not find alias "${alias}". Add contacts with /contact add.`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const txId = createPending({
|
||||
initiatorUserId: senderId,
|
||||
recipientUserId,
|
||||
recipientAddress,
|
||||
amountSats,
|
||||
initiatedVia: "inline",
|
||||
groupChatId: null,
|
||||
});
|
||||
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: `tip_direct_${txId}`,
|
||||
title: `⚡ Send ${amountSats} sats to ${alias}`,
|
||||
description: "Tap to send",
|
||||
input_message_content: {
|
||||
message_text: `⚡ Sending ${amountSats} sats to ${alias}…`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Chosen inline result: actually execute the payment ──────────────────
|
||||
|
||||
bot.on("chosen_inline_result", async (ctx) => {
|
||||
const resultId = ctx.chosenInlineResult.result_id;
|
||||
const senderId = ctx.from.id;
|
||||
|
||||
// Only handle direct tip results (not claim results — those use callback buttons)
|
||||
if (!resultId.startsWith("tip_direct_")) return;
|
||||
|
||||
const txId = resultId.replace("tip_direct_", "");
|
||||
const tx = findPending(txId);
|
||||
|
||||
if (!tx || tx.status !== "awaiting_unlock") return;
|
||||
|
||||
const session = getSession(senderId);
|
||||
|
||||
if (!session) {
|
||||
// Wallet locked — update tx and notify sender
|
||||
updateStatus(txId, "awaiting_unlock"); // already set, but make explicit
|
||||
|
||||
try {
|
||||
await ctx.api.sendMessage(
|
||||
senderId,
|
||||
`Wallet locked. /unlock to send ${tx.amount_sats} sats (pending for 2 min).`
|
||||
);
|
||||
} catch {
|
||||
// DM might be blocked
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(txId, "processing");
|
||||
|
||||
try {
|
||||
await executePay(senderId, tx.recipient_address!, tx.amount_sats);
|
||||
updateStatus(txId, "done");
|
||||
|
||||
// Edit the posted message to show success (requires inline_message_id)
|
||||
const inlineMsgId = ctx.chosenInlineResult.inline_message_id;
|
||||
if (inlineMsgId) {
|
||||
try {
|
||||
await ctx.api.editMessageTextInline(
|
||||
inlineMsgId,
|
||||
`✅ ${tx.amount_sats} sats sent!`
|
||||
);
|
||||
} catch {
|
||||
// best-effort edit
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
updateStatus(txId, "failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing required env var: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(name: string, fallback = ""): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
|
||||
function optionalInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (isNaN(parsed)) throw new Error(`Env var ${name} must be an integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
botToken: required("BOT_TOKEN"),
|
||||
breezApiKey: optional("BREEZ_API_KEY"), // optional until real SDK is wired in
|
||||
databaseUrl: optional("DATABASE_URL", "sqlite:./data/bot.db"),
|
||||
registryUrl: optional("REGISTRY_URL"),
|
||||
registryWriteKey: optional("REGISTRY_WRITE_KEY"),
|
||||
registryReadKey: optional("REGISTRY_READ_KEY"),
|
||||
sessionSweepIntervalMs: optionalInt("SESSION_SWEEP_INTERVAL_MS", 60_000),
|
||||
botInstanceName: optional("BOT_INSTANCE_NAME", "mybot"),
|
||||
nodeEnv: optional("NODE_ENV", "development"),
|
||||
// Mini App / Web API
|
||||
webappPort: optionalInt("WEBAPP_PORT", 3000),
|
||||
webappUrl: optional("WEBAPP_URL"), // public HTTPS URL served to users
|
||||
} as const;
|
||||
|
||||
export type Config = typeof config;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getDb } from "./schema";
|
||||
|
||||
export type ClaimedIdType = "username" | "channel" | "group_admin";
|
||||
|
||||
export interface IdentityClaim {
|
||||
id: number;
|
||||
claimed_id: string;
|
||||
claimed_id_type: ClaimedIdType;
|
||||
owned_by_user_id: number;
|
||||
allow_spending: number; // 1 = true, 0 = false (SQLite boolean)
|
||||
challenge_code: string | null;
|
||||
challenge_expiry: string | null;
|
||||
verified_at: string | null;
|
||||
}
|
||||
|
||||
export function findClaimByIdentity(claimedId: string): IdentityClaim | undefined {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM identity_claims WHERE claimed_id = ?")
|
||||
.get(claimedId) as IdentityClaim | undefined;
|
||||
}
|
||||
|
||||
export function findVerifiedClaimsForUser(userId: number): IdentityClaim[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM identity_claims WHERE owned_by_user_id = ? AND verified_at IS NOT NULL"
|
||||
)
|
||||
.all(userId) as IdentityClaim[];
|
||||
}
|
||||
|
||||
export function upsertChallenge(
|
||||
claimedId: string,
|
||||
claimedIdType: ClaimedIdType,
|
||||
ownerUserId: number,
|
||||
challengeCode: string,
|
||||
expiresAt: Date
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO identity_claims
|
||||
(claimed_id, claimed_id_type, owned_by_user_id, challenge_code, challenge_expiry)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (claimed_id) DO UPDATE SET
|
||||
claimed_id_type = excluded.claimed_id_type,
|
||||
owned_by_user_id = excluded.owned_by_user_id,
|
||||
challenge_code = excluded.challenge_code,
|
||||
challenge_expiry = excluded.challenge_expiry,
|
||||
verified_at = NULL`
|
||||
)
|
||||
.run(claimedId, claimedIdType, ownerUserId, challengeCode, expiresAt.toISOString());
|
||||
}
|
||||
|
||||
export function verifyClaim(claimedId: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE identity_claims
|
||||
SET verified_at = datetime('now'), challenge_code = NULL, challenge_expiry = NULL
|
||||
WHERE claimed_id = ?`
|
||||
)
|
||||
.run(claimedId);
|
||||
}
|
||||
|
||||
export function deleteClaim(claimedId: string, ownerUserId: number): boolean {
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
"DELETE FROM identity_claims WHERE claimed_id = ? AND owned_by_user_id = ?"
|
||||
)
|
||||
.run(claimedId, ownerUserId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function setAllowSpending(
|
||||
claimedId: string,
|
||||
ownerUserId: number,
|
||||
allow: boolean
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE identity_claims SET allow_spending = ? WHERE claimed_id = ? AND owned_by_user_id = ?"
|
||||
)
|
||||
.run(allow ? 1 : 0, claimedId, ownerUserId);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getDb } from "./schema";
|
||||
|
||||
export interface Contact {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
alias: string;
|
||||
target_user_id: number | null;
|
||||
target_address: string | null;
|
||||
}
|
||||
|
||||
export function findContact(ownerUserId: number, alias: string): Contact | undefined {
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM contacts WHERE owner_user_id = ? AND alias = ? COLLATE NOCASE"
|
||||
)
|
||||
.get(ownerUserId, alias) as Contact | undefined;
|
||||
}
|
||||
|
||||
export function listContacts(ownerUserId: number): Contact[] {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM contacts WHERE owner_user_id = ? ORDER BY alias")
|
||||
.all(ownerUserId) as Contact[];
|
||||
}
|
||||
|
||||
export function upsertContact(
|
||||
ownerUserId: number,
|
||||
alias: string,
|
||||
targetUserId: number | null,
|
||||
targetAddress: string | null
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO contacts (owner_user_id, alias, target_user_id, target_address)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (owner_user_id, alias) DO UPDATE SET
|
||||
target_user_id = excluded.target_user_id,
|
||||
target_address = excluded.target_address`
|
||||
)
|
||||
.run(ownerUserId, alias, targetUserId, targetAddress);
|
||||
}
|
||||
|
||||
export function deleteContact(ownerUserId: number, alias: string): boolean {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM contacts WHERE owner_user_id = ? AND alias = ? COLLATE NOCASE")
|
||||
.run(ownerUserId, alias);
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getDb } from "./schema";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type TxStatus = "awaiting_unlock" | "processing" | "done" | "expired" | "failed";
|
||||
export type TxInitiatedVia = "command" | "inline";
|
||||
|
||||
export interface PendingTransaction {
|
||||
id: string;
|
||||
initiator_user_id: number;
|
||||
recipient_user_id: number | null;
|
||||
recipient_address: string | null;
|
||||
amount_sats: number;
|
||||
status: TxStatus;
|
||||
initiated_via: TxInitiatedVia;
|
||||
group_chat_id: number | null;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const TX_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
export function createPending(opts: {
|
||||
initiatorUserId: number;
|
||||
recipientUserId: number | null;
|
||||
recipientAddress: string | null;
|
||||
amountSats: number;
|
||||
initiatedVia: TxInitiatedVia;
|
||||
groupChatId: number | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + TX_TTL_MS).toISOString();
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO pending_transactions
|
||||
(id, initiator_user_id, recipient_user_id, recipient_address,
|
||||
amount_sats, initiated_via, group_chat_id, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
opts.initiatorUserId,
|
||||
opts.recipientUserId,
|
||||
opts.recipientAddress,
|
||||
opts.amountSats,
|
||||
opts.initiatedVia,
|
||||
opts.groupChatId,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function findPending(id: string): PendingTransaction | undefined {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM pending_transactions WHERE id = ?")
|
||||
.get(id) as PendingTransaction | undefined;
|
||||
}
|
||||
|
||||
export function findPendingForUser(initiatorUserId: number): PendingTransaction[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT * FROM pending_transactions
|
||||
WHERE initiator_user_id = ? AND status = 'awaiting_unlock'
|
||||
AND expires_at > datetime('now')
|
||||
ORDER BY created_at`
|
||||
)
|
||||
.all(initiatorUserId) as PendingTransaction[];
|
||||
}
|
||||
|
||||
export function updateStatus(id: string, status: TxStatus): void {
|
||||
getDb()
|
||||
.prepare("UPDATE pending_transactions SET status = ? WHERE id = ?")
|
||||
.run(status, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a pending transaction for a recipient.
|
||||
* Uses a single UPDATE with a WHERE status guard so two concurrent callers
|
||||
* cannot both succeed — only the one whose UPDATE lands first gets changes = 1.
|
||||
* Returns true iff this caller won the claim.
|
||||
*/
|
||||
export function claimForRecipient(
|
||||
txId: string,
|
||||
recipientUserId: number,
|
||||
recipientAddress: string
|
||||
): boolean {
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`UPDATE pending_transactions
|
||||
SET recipient_user_id = ?, recipient_address = ?, status = 'processing'
|
||||
WHERE id = ? AND status = 'awaiting_unlock' AND expires_at > datetime('now')`
|
||||
)
|
||||
.run(recipientUserId, recipientAddress, txId);
|
||||
return result.changes === 1;
|
||||
}
|
||||
|
||||
export function expireStale(): number {
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`UPDATE pending_transactions SET status = 'expired'
|
||||
WHERE status = 'awaiting_unlock' AND expires_at <= datetime('now')`
|
||||
)
|
||||
.run();
|
||||
return result.changes;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { config } from "../config";
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (_db) return _db;
|
||||
|
||||
const url = config.databaseUrl;
|
||||
if (!url.startsWith("sqlite:")) {
|
||||
throw new Error("Only SQLite is supported via this module. Use DATABASE_URL=sqlite:./path/to/db");
|
||||
}
|
||||
|
||||
const filePath = url.slice("sqlite:".length);
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
_db = new Database(filePath);
|
||||
_db.pragma("journal_mode = WAL");
|
||||
_db.pragma("foreign_keys = ON");
|
||||
|
||||
applySchema(_db);
|
||||
return _db;
|
||||
}
|
||||
|
||||
function applySchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
encrypted_mnemonic TEXT NOT NULL,
|
||||
spark_address TEXT,
|
||||
unlock_duration INTEGER NOT NULL DEFAULT 3600,
|
||||
onboarded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity_claims (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claimed_id TEXT NOT NULL,
|
||||
claimed_id_type TEXT NOT NULL CHECK (claimed_id_type IN ('username', 'channel', 'group_admin')),
|
||||
owned_by_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
allow_spending INTEGER NOT NULL DEFAULT 1,
|
||||
challenge_code TEXT,
|
||||
challenge_expiry TEXT,
|
||||
verified_at TEXT,
|
||||
UNIQUE (claimed_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
alias TEXT NOT NULL,
|
||||
target_user_id INTEGER,
|
||||
target_address TEXT,
|
||||
UNIQUE (owner_user_id, alias)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
initiator_user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
recipient_user_id INTEGER,
|
||||
recipient_address TEXT,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'awaiting_unlock'
|
||||
CHECK (status IN ('awaiting_unlock', 'processing', 'done', 'expired', 'failed')),
|
||||
initiated_via TEXT NOT NULL CHECK (initiated_via IN ('command', 'inline')),
|
||||
group_chat_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federated_registry (
|
||||
telegram_id INTEGER PRIMARY KEY,
|
||||
spark_address TEXT NOT NULL,
|
||||
published_by TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getDb } from "./schema";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string | null;
|
||||
encrypted_mnemonic: string;
|
||||
spark_address: string | null;
|
||||
unlock_duration: number;
|
||||
onboarded_at: string;
|
||||
}
|
||||
|
||||
export function findUser(userId: number): User | undefined {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM users WHERE id = ?")
|
||||
.get(userId) as User | undefined;
|
||||
}
|
||||
|
||||
export function createUser(
|
||||
userId: number,
|
||||
username: string | null,
|
||||
encryptedMnemonic: string,
|
||||
sparkAddress: string | null
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, username, encrypted_mnemonic, spark_address)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(userId, username, encryptedMnemonic, sparkAddress);
|
||||
}
|
||||
|
||||
export function updateSparkAddress(userId: number, sparkAddress: string): void {
|
||||
getDb()
|
||||
.prepare("UPDATE users SET spark_address = ? WHERE id = ?")
|
||||
.run(sparkAddress, userId);
|
||||
}
|
||||
|
||||
export function updateUsername(userId: number, username: string | null): void {
|
||||
getDb()
|
||||
.prepare("UPDATE users SET username = ? WHERE id = ?")
|
||||
.run(username, userId);
|
||||
}
|
||||
|
||||
export function updateUnlockDuration(userId: number, seconds: number): void {
|
||||
getDb()
|
||||
.prepare("UPDATE users SET unlock_duration = ? WHERE id = ?")
|
||||
.run(seconds, userId);
|
||||
}
|
||||
|
||||
export function updateEncryptedMnemonic(userId: number, encrypted: string): void {
|
||||
getDb()
|
||||
.prepare("UPDATE users SET encrypted_mnemonic = ? WHERE id = ?")
|
||||
.run(encrypted, userId);
|
||||
}
|
||||
|
||||
export function getAllUserIds(): number[] {
|
||||
const rows = getDb()
|
||||
.prepare("SELECT id FROM users")
|
||||
.all() as { id: number }[];
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* AES-256-GCM encryption for user mnemonics.
|
||||
* The PIN/passphrase is stretched with scrypt before use as an AES key.
|
||||
*
|
||||
* Encrypted format (base64-encoded):
|
||||
* salt(32) + iv(12) + authTag(16) + ciphertext
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
||||
|
||||
const SALT_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
const KEY_LEN = 32; // AES-256
|
||||
const TAG_LEN = 16;
|
||||
const SCRYPT_N = 16384;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
|
||||
function deriveKey(pin: string, salt: Buffer): Buffer {
|
||||
return scryptSync(pin, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }) as Buffer;
|
||||
}
|
||||
|
||||
export function encryptMnemonic(mnemonic: string, pin: string): string {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const key = deriveKey(pin, salt);
|
||||
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(mnemonic, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const blob = Buffer.concat([salt, iv, authTag, encrypted]);
|
||||
return blob.toString("base64");
|
||||
}
|
||||
|
||||
export function decryptMnemonic(encryptedBlob: string, pin: string): string {
|
||||
const blob = Buffer.from(encryptedBlob, "base64");
|
||||
|
||||
const salt = blob.subarray(0, SALT_LEN);
|
||||
const iv = blob.subarray(SALT_LEN, SALT_LEN + IV_LEN);
|
||||
const authTag = blob.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
|
||||
const ciphertext = blob.subarray(SALT_LEN + IV_LEN + TAG_LEN);
|
||||
|
||||
const key = deriveKey(pin, salt);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
try {
|
||||
return decipher.update(ciphertext) + decipher.final("utf8");
|
||||
} catch {
|
||||
throw new Error("Invalid PIN or corrupted data.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Federated registry client.
|
||||
*
|
||||
* Reads from / writes to a remote HTTP registry and maintains a local cache
|
||||
* in the federated_registry SQLite table. The local DB always takes priority.
|
||||
*/
|
||||
|
||||
import { getDb } from "../db/schema";
|
||||
import { config } from "../config";
|
||||
|
||||
// Permit spark1… addresses, Lightning addresses (user@domain), and on-chain bech32
|
||||
const ADDRESS_RE = /^(spark1[a-z0-9]{10,}|[^@\s]{1,64}@[^@\s]{1,255}|bc1[a-z0-9]{25,87}|[13][a-zA-Z0-9]{25,34})$/;
|
||||
|
||||
function isValidAddress(addr: string): boolean {
|
||||
return addr.length <= 400 && ADDRESS_RE.test(addr);
|
||||
}
|
||||
|
||||
export interface RegistryEntry {
|
||||
telegram_id: number;
|
||||
spark_address: string;
|
||||
published_by: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ─── Local cache (federated_registry table) ───────────────────────────────────
|
||||
|
||||
export function lookupInRegistry(telegramId: number): string | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT spark_address FROM federated_registry WHERE telegram_id = ?")
|
||||
.get(telegramId) as { spark_address: string } | undefined;
|
||||
return row?.spark_address ?? null;
|
||||
}
|
||||
|
||||
function upsertLocal(telegramId: number, sparkAddress: string, publishedBy: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO federated_registry (telegram_id, spark_address, published_by, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (telegram_id) DO UPDATE SET
|
||||
spark_address = excluded.spark_address,
|
||||
published_by = excluded.published_by,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(telegramId, sparkAddress, publishedBy);
|
||||
}
|
||||
|
||||
// ─── Remote publish/fetch ─────────────────────────────────────────────────────
|
||||
|
||||
export async function publishToRegistry(
|
||||
telegramId: number,
|
||||
sparkAddress: string
|
||||
): Promise<void> {
|
||||
if (!config.registryUrl || !config.registryWriteKey) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.registryUrl}/entries`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.registryWriteKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
telegram_id: telegramId,
|
||||
spark_address: sparkAddress,
|
||||
published_by: config.botInstanceName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[registry] publish failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[registry] publish error:", err);
|
||||
}
|
||||
|
||||
// Always update local cache regardless of remote result
|
||||
upsertLocal(telegramId, sparkAddress, config.botInstanceName);
|
||||
}
|
||||
|
||||
export async function fetchFromRegistry(telegramId: number): Promise<string | null> {
|
||||
// Check local cache first
|
||||
const cached = lookupInRegistry(telegramId);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!config.registryUrl) return null;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.registryReadKey) headers["Authorization"] = `Bearer ${config.registryReadKey}`;
|
||||
|
||||
const response = await fetch(`${config.registryUrl}/entries/${telegramId}`, { headers });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as { spark_address?: string; published_by?: string };
|
||||
if (!data.spark_address || !isValidAddress(data.spark_address)) return null;
|
||||
|
||||
upsertLocal(telegramId, data.spark_address, data.published_by ?? "unknown");
|
||||
return data.spark_address;
|
||||
} catch (err) {
|
||||
console.warn("[registry] fetch error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Identity resolution (full chain) ─────────────────────────────────────────
|
||||
|
||||
import { findContact } from "../db/contacts";
|
||||
import { findUser } from "../db/users";
|
||||
import { findClaimByIdentity } from "../db/claims";
|
||||
|
||||
export async function resolveRecipient(
|
||||
senderUserId: number,
|
||||
targetUsernameOrId: string | number | null,
|
||||
replyToUserId?: number
|
||||
): Promise<{ userId: number | null; address: string | null }> {
|
||||
// 1. If replying to a message, try that user ID directly
|
||||
if (replyToUserId != null) {
|
||||
const user = findUser(replyToUserId);
|
||||
if (user?.spark_address) return { userId: replyToUserId, address: user.spark_address };
|
||||
|
||||
const claim = findClaimByIdentity(String(replyToUserId));
|
||||
if (claim?.verified_at && claim.allow_spending) {
|
||||
const owner = findUser(claim.owned_by_user_id);
|
||||
if (owner?.spark_address) return { userId: owner.id, address: owner.spark_address };
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUsernameOrId) return { userId: null, address: null };
|
||||
|
||||
const target = String(targetUsernameOrId).toLowerCase().replace(/^@/, "");
|
||||
|
||||
// 2. Sender's contacts/aliases
|
||||
const contact = findContact(senderUserId, target);
|
||||
if (contact) {
|
||||
if (contact.target_address) return { userId: contact.target_user_id, address: contact.target_address };
|
||||
if (contact.target_user_id) {
|
||||
const user = findUser(contact.target_user_id);
|
||||
if (user?.spark_address) return { userId: user.id, address: user.spark_address };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Direct user lookup by numeric ID or username
|
||||
const numericId = Number(target);
|
||||
if (!isNaN(numericId)) {
|
||||
const user = findUser(numericId);
|
||||
if (user?.spark_address) return { userId: user.id, address: user.spark_address };
|
||||
} else {
|
||||
// Username lookup — find user where username matches
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM users WHERE LOWER(username) = ?")
|
||||
.get(target) as { id: number; spark_address: string | null } | undefined;
|
||||
if (row?.spark_address) return { userId: row.id, address: row.spark_address };
|
||||
}
|
||||
|
||||
// 4. Identity claims
|
||||
const claim = findClaimByIdentity(target) ?? findClaimByIdentity(`@${target}`);
|
||||
if (claim?.verified_at && claim.allow_spending) {
|
||||
const owner = findUser(claim.owned_by_user_id);
|
||||
if (owner?.spark_address) return { userId: owner.id, address: owner.spark_address };
|
||||
}
|
||||
|
||||
// 5. Federated registry
|
||||
if (!isNaN(numericId)) {
|
||||
const addr = await fetchFromRegistry(numericId);
|
||||
if (addr) return { userId: numericId, address: addr };
|
||||
}
|
||||
|
||||
return { userId: null, address: null };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { config } from "../config";
|
||||
|
||||
export type SessionPolicy = "timed" | "tx-only";
|
||||
|
||||
export interface UnlockSession {
|
||||
userId: number;
|
||||
decryptedMnemonic: string;
|
||||
unlockedAt: Date;
|
||||
expiresAt: Date;
|
||||
policy: SessionPolicy;
|
||||
txRemaining?: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<number, UnlockSession>();
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startSweep(onExpired?: (userId: number) => void): void {
|
||||
if (sweepTimer) return;
|
||||
sweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [userId, session] of sessions) {
|
||||
if (session.expiresAt.getTime() <= now) {
|
||||
wipeSession(session);
|
||||
sessions.delete(userId);
|
||||
onExpired?.(userId);
|
||||
}
|
||||
}
|
||||
}, config.sessionSweepIntervalMs);
|
||||
|
||||
// Prevent the timer from keeping the process alive
|
||||
sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
export function stopSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
userId: number,
|
||||
decryptedMnemonic: string,
|
||||
policy: SessionPolicy,
|
||||
durationSeconds: number,
|
||||
txCount?: number
|
||||
): UnlockSession {
|
||||
// Wipe any existing session first
|
||||
const existing = sessions.get(userId);
|
||||
if (existing) wipeSession(existing);
|
||||
|
||||
const now = new Date();
|
||||
const session: UnlockSession = {
|
||||
userId,
|
||||
decryptedMnemonic,
|
||||
unlockedAt: now,
|
||||
expiresAt: new Date(now.getTime() + durationSeconds * 1000),
|
||||
policy,
|
||||
txRemaining: policy === "tx-only" ? (txCount ?? 1) : undefined,
|
||||
};
|
||||
|
||||
sessions.set(userId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getSession(userId: number): UnlockSession | undefined {
|
||||
const session = sessions.get(userId);
|
||||
if (!session) return undefined;
|
||||
|
||||
if (session.expiresAt.getTime() <= Date.now()) {
|
||||
wipeSession(session);
|
||||
sessions.delete(userId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function consumeTx(userId: number): boolean {
|
||||
const session = getSession(userId);
|
||||
if (!session) return false;
|
||||
|
||||
if (session.policy === "tx-only") {
|
||||
session.txRemaining = (session.txRemaining ?? 1) - 1;
|
||||
if (session.txRemaining <= 0) {
|
||||
wipeSession(session);
|
||||
sessions.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function destroySession(userId: number): boolean {
|
||||
const session = sessions.get(userId);
|
||||
if (!session) return false;
|
||||
wipeSession(session);
|
||||
sessions.delete(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAllActiveUserIds(): number[] {
|
||||
return Array.from(sessions.keys());
|
||||
}
|
||||
|
||||
/** Zero-fill the mnemonic string before discarding the reference. */
|
||||
function wipeSession(session: UnlockSession): void {
|
||||
// Overwrite the string in-place as much as JS allows
|
||||
(session as { decryptedMnemonic: string }).decryptedMnemonic = "\0".repeat(
|
||||
session.decryptedMnemonic.length
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Breez SDK Spark wrapper.
|
||||
*
|
||||
* One SDK instance is created per user on unlock and destroyed on lock/expiry.
|
||||
* The actual SDK import is isolated here so the rest of the codebase never
|
||||
* touches it directly — swap the implementation without touching callers.
|
||||
*
|
||||
* TODO: Replace the stub implementations with real @breeztech/breez-sdk-spark calls
|
||||
* once the package API is confirmed. Each stub is marked with a TODO comment.
|
||||
*/
|
||||
|
||||
import * as bip39 from "bip39";
|
||||
import { config } from "../config";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WalletInfo {
|
||||
sparkAddress: string;
|
||||
balanceSats: number;
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
txId: string;
|
||||
feeSats: number;
|
||||
}
|
||||
|
||||
export interface ReceiveAddresses {
|
||||
sparkAddress: string;
|
||||
lightningInvoice: string | null; // BOLT11, null if no amount specified
|
||||
onchainAddress: string;
|
||||
}
|
||||
|
||||
export type PaymentType = "lightning" | "spark" | "onchain";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SdkInstance = any;
|
||||
|
||||
// ─── Active wallet instances (userId → SDK instance) ─────────────────────────
|
||||
|
||||
const activeWallets = new Map<number, SdkInstance>();
|
||||
|
||||
// ─── Mnemonic generation ──────────────────────────────────────────────────────
|
||||
|
||||
export function generateMnemonic(): string {
|
||||
return bip39.generateMnemonic(128); // 12 words
|
||||
}
|
||||
|
||||
export function validateMnemonic(mnemonic: string): boolean {
|
||||
return bip39.validateMnemonic(mnemonic);
|
||||
}
|
||||
|
||||
// ─── SDK lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function connectWallet(userId: number, mnemonic: string): Promise<WalletInfo> {
|
||||
// TODO: replace with real SDK init
|
||||
// const sdk = await BreezSdkSpark.connect({
|
||||
// mnemonic,
|
||||
// apiKey: config.breezApiKey,
|
||||
// network: 'mainnet',
|
||||
// });
|
||||
|
||||
const mockSdk: SdkInstance = {
|
||||
_mnemonic: mnemonic,
|
||||
_userId: userId,
|
||||
// TODO: derive real spark address from SDK
|
||||
sparkAddress: `spark1mock${userId}`,
|
||||
// TODO: fetch real balance
|
||||
balanceSats: 0,
|
||||
};
|
||||
|
||||
activeWallets.set(userId, mockSdk);
|
||||
|
||||
return {
|
||||
sparkAddress: mockSdk.sparkAddress,
|
||||
balanceSats: mockSdk.balanceSats,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWalletInfo(userId: number): Promise<WalletInfo | null> {
|
||||
const sdk = activeWallets.get(userId);
|
||||
if (!sdk) return null;
|
||||
|
||||
// TODO: await sdk.getBalance() and sdk.getAddress()
|
||||
return {
|
||||
sparkAddress: sdk.sparkAddress as string,
|
||||
balanceSats: sdk.balanceSats as number,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPayment(
|
||||
userId: number,
|
||||
recipientAddress: string,
|
||||
amountSats: number
|
||||
): Promise<SendResult> {
|
||||
const sdk = activeWallets.get(userId);
|
||||
if (!sdk) throw new Error("Wallet not connected. /unlock first.");
|
||||
|
||||
// TODO: const result = await sdk.sendPayment({ destination: recipientAddress, amountSats });
|
||||
// return { txId: result.txId, feeSats: result.feeSats };
|
||||
|
||||
const txId = `mock_tx_${Date.now()}`;
|
||||
console.log(
|
||||
`[wallet] MOCK sendPayment: user=${userId} to=${recipientAddress} amount=${amountSats} txId=${txId}`
|
||||
);
|
||||
return { txId, feeSats: 0 };
|
||||
}
|
||||
|
||||
export async function getReceiveAddresses(
|
||||
userId: number,
|
||||
amountSats?: number,
|
||||
description?: string
|
||||
): Promise<ReceiveAddresses> {
|
||||
const sdk = activeWallets.get(userId);
|
||||
if (!sdk) throw new Error("Wallet not connected. Unlock first.");
|
||||
|
||||
// TODO: real SDK calls:
|
||||
// const invoice = await sdk.receivePayment({ amountSats, description }); // BOLT11
|
||||
// const onchain = await sdk.getOnchainAddress(); // Bitcoin address
|
||||
|
||||
const mockInvoice = amountSats
|
||||
? `lnbc${amountSats}u1mock_invoice_for_user_${userId}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
sparkAddress: sdk.sparkAddress as string,
|
||||
lightningInvoice: mockInvoice,
|
||||
onchainAddress: `bc1qmock_onchain_${userId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPaymentType(destination: string): Promise<PaymentType> {
|
||||
const lower = destination.toLowerCase();
|
||||
if (lower.startsWith("lnbc") || lower.startsWith("lntb") || lower.startsWith("lnurl")) {
|
||||
return "lightning";
|
||||
}
|
||||
if (lower.startsWith("spark1") || lower.includes("@")) {
|
||||
return "spark";
|
||||
}
|
||||
return "onchain";
|
||||
}
|
||||
|
||||
export async function disconnectWallet(userId: number): Promise<void> {
|
||||
const sdk = activeWallets.get(userId);
|
||||
if (!sdk) return;
|
||||
|
||||
// TODO: await sdk.disconnect();
|
||||
activeWallets.delete(userId);
|
||||
}
|
||||
|
||||
export function isWalletConnected(userId: number): boolean {
|
||||
return activeWallets.has(userId);
|
||||
}
|
||||
|
||||
// ─── Spark address derivation (offline, before session) ───────────────────────
|
||||
|
||||
export async function deriveSparkAddress(mnemonic: string): Promise<string> {
|
||||
// TODO: replace with real SDK address derivation
|
||||
// const addr = await BreezSdkSpark.deriveAddress({ mnemonic, apiKey: config.breezApiKey });
|
||||
const seed = await bip39.mnemonicToSeed(mnemonic);
|
||||
return `spark1${seed.slice(0, 8).toString("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
const tg = window.Telegram.WebApp;
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
|
||||
function walletApp() {
|
||||
return {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
tab: "dashboard",
|
||||
botName: tg.initDataUnsafe?.bot?.username ?? "Wallet",
|
||||
toastVisible: false,
|
||||
|
||||
wallet: {
|
||||
registered: false,
|
||||
locked: true,
|
||||
balanceSats: 0,
|
||||
sparkAddress: "",
|
||||
unlockExpiresAt: null,
|
||||
sessionPolicy: null,
|
||||
},
|
||||
|
||||
// Setup (first launch, no wallet)
|
||||
setup: {
|
||||
pin: "",
|
||||
confirmPin: "",
|
||||
error: "",
|
||||
creating: false,
|
||||
},
|
||||
|
||||
// Seed reveal
|
||||
seed: {
|
||||
screen: false,
|
||||
loading: false,
|
||||
words: [],
|
||||
error: "",
|
||||
confirmed: false,
|
||||
},
|
||||
|
||||
// PIN unlock overlay
|
||||
showPinOverlay: false,
|
||||
pin: "",
|
||||
pinError: "",
|
||||
unlocking: false,
|
||||
|
||||
// Send
|
||||
send: {
|
||||
destination: "",
|
||||
amountSats: "",
|
||||
description: "",
|
||||
detectedType: "",
|
||||
sending: false,
|
||||
error: "",
|
||||
success: "",
|
||||
},
|
||||
|
||||
// Receive
|
||||
receive: {
|
||||
tab: "spark",
|
||||
sparkAddress: "",
|
||||
lightningInvoice: null,
|
||||
onchainAddress: null,
|
||||
amountSats: "",
|
||||
},
|
||||
|
||||
// History
|
||||
history: {
|
||||
items: [],
|
||||
loading: false,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
},
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────
|
||||
async init() {
|
||||
if (!tg.initData) {
|
||||
document.body.innerHTML = "<p style='padding:2rem;color:red'>Open this app from Telegram.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed reveal via one-time token (from bot onboarding link)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const seedToken = params.get("seedToken");
|
||||
if (seedToken && window.location.hash === "#seed") {
|
||||
await this.revealSeed(seedToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
// ── API ────────────────────────────────────────────────────────────────
|
||||
async api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Init-Data": tg.initData,
|
||||
},
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Request failed");
|
||||
return data;
|
||||
},
|
||||
|
||||
// ── Wallet state ───────────────────────────────────────────────────────
|
||||
async refresh() {
|
||||
try {
|
||||
const data = await this.api("GET", "/api/wallet");
|
||||
|
||||
if (!data.registered) {
|
||||
this.tab = "setup";
|
||||
return;
|
||||
}
|
||||
|
||||
this.wallet = data;
|
||||
this.receive.sparkAddress = data.sparkAddress;
|
||||
this.$nextTick(() => this.renderQr("qr-spark", data.sparkAddress));
|
||||
} catch (e) {
|
||||
console.error("refresh:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Setup — create wallet ──────────────────────────────────────────────
|
||||
async createWallet() {
|
||||
this.setup.error = "";
|
||||
|
||||
if (this.setup.pin.length < 6) {
|
||||
this.setup.error = "PIN must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
if (this.setup.pin !== this.setup.confirmPin) {
|
||||
this.setup.error = "PINs do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.setup.creating = true;
|
||||
try {
|
||||
const data = await this.api("POST", "/api/setup", { pin: this.setup.pin });
|
||||
this.setup.pin = "";
|
||||
this.setup.confirmPin = "";
|
||||
|
||||
// Show seed reveal screen immediately after creation
|
||||
this.seed.words = data.words;
|
||||
this.seed.screen = true;
|
||||
this.seed.confirmed = false;
|
||||
|
||||
// Pre-load wallet state for after seed is dismissed
|
||||
this.wallet.registered = true;
|
||||
this.wallet.sparkAddress = data.sparkAddress;
|
||||
this.receive.sparkAddress = data.sparkAddress;
|
||||
} catch (e) {
|
||||
this.setup.error = e.message;
|
||||
} finally {
|
||||
this.setup.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Seed reveal — via one-time URL token (from bot link) ───────────────
|
||||
async revealSeed(token) {
|
||||
this.seed.screen = true;
|
||||
this.seed.loading = true;
|
||||
try {
|
||||
const data = await this.api("GET", `/api/seed/reveal?token=${encodeURIComponent(token)}`);
|
||||
this.seed.words = data.words;
|
||||
} catch (e) {
|
||||
this.seed.error = e.message || "Link already used or expired.";
|
||||
} finally {
|
||||
this.seed.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async closeSeedScreen() {
|
||||
this.seed.words = [];
|
||||
this.seed.screen = false;
|
||||
this.seed.confirmed = false;
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
// ── Unlock / Lock ──────────────────────────────────────────────────────
|
||||
async unlock() {
|
||||
if (!this.pin) return;
|
||||
this.unlocking = true;
|
||||
this.pinError = "";
|
||||
try {
|
||||
await this.api("POST", "/api/unlock", { pin: this.pin });
|
||||
this.showPinOverlay = false;
|
||||
this.pin = "";
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
this.pinError = e.message;
|
||||
} finally {
|
||||
this.unlocking = false;
|
||||
}
|
||||
},
|
||||
|
||||
async lock() {
|
||||
await this.api("POST", "/api/lock");
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
// ── Send ───────────────────────────────────────────────────────────────
|
||||
detectType() {
|
||||
const d = this.send.destination.trim().toLowerCase();
|
||||
if (!d) { this.send.detectedType = ""; return; }
|
||||
if (d.startsWith("lnbc") || d.startsWith("lntb") || d.startsWith("lnurl")) {
|
||||
this.send.detectedType = "⚡ Lightning";
|
||||
} else if (d.startsWith("spark1") || d.includes("@")) {
|
||||
this.send.detectedType = "✳️ Spark";
|
||||
} else {
|
||||
this.send.detectedType = "₿ On-chain";
|
||||
}
|
||||
},
|
||||
|
||||
async sendPayment() {
|
||||
this.send.error = "";
|
||||
this.send.success = "";
|
||||
if (!this.send.destination.trim()) { this.send.error = "Enter a destination."; return; }
|
||||
|
||||
this.send.sending = true;
|
||||
try {
|
||||
const body = {
|
||||
destination: this.send.destination.trim(),
|
||||
amountSats: this.send.amountSats ? parseInt(this.send.amountSats, 10) : undefined,
|
||||
description: this.send.description || undefined,
|
||||
};
|
||||
const res = await this.api("POST", "/api/send", body);
|
||||
this.send.success = `✅ Sent! Fee: ${res.feeSats} sats`;
|
||||
this.send.destination = "";
|
||||
this.send.amountSats = "";
|
||||
this.send.description = "";
|
||||
this.send.detectedType = "";
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
if (e.message === "wallet_locked") {
|
||||
this.showPinOverlay = true;
|
||||
} else {
|
||||
this.send.error = e.message;
|
||||
}
|
||||
} finally {
|
||||
this.send.sending = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Receive ────────────────────────────────────────────────────────────
|
||||
async loadReceive() {
|
||||
const params = new URLSearchParams();
|
||||
if (this.receive.amountSats) params.set("amount", this.receive.amountSats);
|
||||
try {
|
||||
const data = await this.api("GET", `/api/receive?${params}`);
|
||||
this.receive.sparkAddress = data.sparkAddress;
|
||||
this.receive.lightningInvoice = data.lightningInvoice;
|
||||
this.receive.onchainAddress = data.onchainAddress;
|
||||
this.$nextTick(() => {
|
||||
this.renderQr("qr-spark", data.sparkAddress);
|
||||
if (data.lightningInvoice) this.renderQr("qr-lightning", data.lightningInvoice);
|
||||
if (data.onchainAddress) this.renderQr("qr-onchain", data.onchainAddress);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("loadReceive:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────
|
||||
async loadHistory() {
|
||||
this.history.loading = true;
|
||||
this.history.offset = 0;
|
||||
try {
|
||||
const data = await this.api("GET", `/api/history?limit=${this.history.limit}&offset=0`);
|
||||
this.history.items = data.transactions;
|
||||
} catch (e) {
|
||||
console.error("loadHistory:", e.message);
|
||||
} finally {
|
||||
this.history.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
this.history.offset += this.history.limit;
|
||||
try {
|
||||
const data = await this.api("GET", `/api/history?limit=${this.history.limit}&offset=${this.history.offset}`);
|
||||
this.history.items.push(...data.transactions);
|
||||
} catch (e) {
|
||||
console.error("loadMore:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ── QR ─────────────────────────────────────────────────────────────────
|
||||
renderQr(canvasId, text) {
|
||||
if (!text) return;
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
QRCode.toCanvas(canvas, text, { width: 220, margin: 2, color: { dark: "#000", light: "#fff" } });
|
||||
},
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
satsToBtc(sats) { return (sats / 1e8).toFixed(8); },
|
||||
|
||||
formatExpiry(iso) {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
},
|
||||
|
||||
formatDate(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso + "Z");
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
},
|
||||
|
||||
txIcon(status) {
|
||||
return { done: "✅", failed: "❌", processing: "⏳", expired: "💨", awaiting_unlock: "🔒" }[status] ?? "•";
|
||||
},
|
||||
|
||||
async copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.toastVisible = true;
|
||||
setTimeout(() => { this.toastVisible = false; }, 1500);
|
||||
} catch {
|
||||
tg.showAlert("Copy: " + text);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Lightning Wallet</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
|
||||
<body x-data="walletApp()" x-init="init()" class="app">
|
||||
|
||||
<!-- ── Setup screen (no wallet yet) ─────────────────────────────────── -->
|
||||
<div x-show="tab === 'setup'" class="setup-screen">
|
||||
<div class="setup-inner">
|
||||
<div class="setup-icon">⚡</div>
|
||||
<h1 class="setup-title">Create Your Wallet</h1>
|
||||
<p class="setup-sub">
|
||||
Choose a PIN to encrypt your wallet seed.<br>
|
||||
Your PIN is never stored — only you know it.
|
||||
</p>
|
||||
|
||||
<label class="field-label">PIN <span class="hint">(min 6 characters)</span></label>
|
||||
<input
|
||||
x-model="setup.pin"
|
||||
type="password"
|
||||
placeholder="Enter PIN"
|
||||
class="field-input"
|
||||
autocomplete="new-password"
|
||||
@keydown.enter="$refs.confirmPin.focus()"
|
||||
/>
|
||||
|
||||
<label class="field-label" style="margin-top:12px">Confirm PIN</label>
|
||||
<input
|
||||
x-ref="confirmPin"
|
||||
x-model="setup.confirmPin"
|
||||
type="password"
|
||||
placeholder="Repeat PIN"
|
||||
class="field-input"
|
||||
autocomplete="new-password"
|
||||
@keydown.enter="createWallet()"
|
||||
/>
|
||||
|
||||
<div x-show="setup.error" class="error-msg" x-text="setup.error"></div>
|
||||
|
||||
<button
|
||||
@click="createWallet()"
|
||||
class="btn-primary btn-wide"
|
||||
:disabled="setup.creating || !setup.pin || !setup.confirmPin"
|
||||
style="margin-top:20px"
|
||||
>
|
||||
<span x-show="!setup.creating">Create Wallet</span>
|
||||
<span x-show="setup.creating">Creating…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Seed reveal screen ────────────────────────────────────────────── -->
|
||||
<div x-show="seed.screen" class="seed-screen" x-transition>
|
||||
<div x-show="seed.loading" class="seed-loading">Loading your recovery seed…</div>
|
||||
|
||||
<template x-if="seed.error">
|
||||
<div class="seed-error-wrap">
|
||||
<p class="seed-error-icon">⚠️</p>
|
||||
<p class="seed-error-msg" x-text="seed.error"></p>
|
||||
<p class="hint" style="margin-top:8px">Close this window and contact the bot.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!seed.loading && !seed.error && seed.words.length">
|
||||
<div class="seed-content">
|
||||
<h1 class="seed-title">Your Recovery Seed</h1>
|
||||
<p class="seed-subtitle">
|
||||
Write these 12 words down on paper and store them offline.<br>
|
||||
<strong>Anyone with these words can access your funds.</strong>
|
||||
</p>
|
||||
|
||||
<ol class="seed-grid">
|
||||
<template x-for="(word, i) in seed.words" :key="i">
|
||||
<li class="seed-word">
|
||||
<span class="seed-num" x-text="i + 1"></span>
|
||||
<span class="seed-val" x-text="word"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
|
||||
<div class="seed-confirm-wrap">
|
||||
<label class="seed-check">
|
||||
<input type="checkbox" x-model="seed.confirmed" />
|
||||
I have written down all 12 words in the correct order.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="closeSeedScreen()"
|
||||
class="btn-primary btn-wide"
|
||||
:disabled="!seed.confirmed"
|
||||
x-show="seed.confirmed"
|
||||
x-transition
|
||||
>
|
||||
Done — open my wallet
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── PIN unlock overlay ───────────────────────────────────────────── -->
|
||||
<div x-show="showPinOverlay" class="overlay" x-transition>
|
||||
<div class="pin-card">
|
||||
<h2 class="pin-title">Unlock Wallet</h2>
|
||||
<p class="pin-hint">Enter your PIN to unlock</p>
|
||||
<input
|
||||
x-model="pin"
|
||||
type="password"
|
||||
placeholder="PIN"
|
||||
class="pin-input"
|
||||
@keydown.enter="unlock()"
|
||||
/>
|
||||
<div x-show="pinError" class="pin-error" x-text="pinError"></div>
|
||||
<button @click="unlock()" class="btn-primary" :disabled="unlocking">
|
||||
<span x-show="!unlocking">Unlock</span>
|
||||
<span x-show="unlocking">Unlocking…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Normal wallet UI (hidden during setup/seed screens) ──────────── -->
|
||||
<template x-if="tab !== 'setup' && !seed.screen">
|
||||
|
||||
<div class="wallet-ui">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<span class="header-title" x-text="botName"></span>
|
||||
<div class="lock-badge" :class="wallet.locked ? 'locked' : 'unlocked'">
|
||||
<span x-text="wallet.locked ? '🔒 Locked' : '🔓 Unlocked'"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tab content -->
|
||||
<main class="content">
|
||||
|
||||
<!-- Dashboard -->
|
||||
<section x-show="tab === 'dashboard'" x-transition>
|
||||
<div class="balance-card">
|
||||
<div class="balance-label">Balance</div>
|
||||
<div class="balance-amount">
|
||||
<span x-text="wallet.locked ? '—' : wallet.balanceSats.toLocaleString()"></span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<div class="balance-btc" x-show="!wallet.locked">
|
||||
≈ <span x-text="satsToBtc(wallet.balanceSats)"></span> BTC
|
||||
</div>
|
||||
<div x-show="wallet.locked" class="unlock-prompt">
|
||||
<button @click="showPinOverlay = true" class="btn-primary">Unlock to see balance</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<button @click="tab = 'send'" class="quick-btn">
|
||||
<span class="quick-icon">↑</span><span>Send</span>
|
||||
</button>
|
||||
<button @click="tab = 'receive'; loadReceive()" class="quick-btn">
|
||||
<span class="quick-icon">↓</span><span>Receive</span>
|
||||
</button>
|
||||
<button @click="tab = 'history'; loadHistory()" class="quick-btn">
|
||||
<span class="quick-icon">🕐</span><span>History</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="!wallet.locked" class="address-row">
|
||||
<div class="address-label">Spark address</div>
|
||||
<div class="address-value" x-text="wallet.sparkAddress" @click="copyText(wallet.sparkAddress)"></div>
|
||||
</div>
|
||||
|
||||
<div x-show="!wallet.locked" class="session-info">
|
||||
<button @click="lock()" class="btn-secondary btn-sm">Lock wallet</button>
|
||||
<span class="hint" x-show="wallet.unlockExpiresAt">
|
||||
Expires <span x-text="formatExpiry(wallet.unlockExpiresAt)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Send -->
|
||||
<section x-show="tab === 'send'" x-transition>
|
||||
<h2 class="section-title">Send</h2>
|
||||
|
||||
<div x-show="wallet.locked" class="lock-notice">
|
||||
<p>Wallet is locked.</p>
|
||||
<button @click="showPinOverlay = true" class="btn-primary">Unlock</button>
|
||||
</div>
|
||||
|
||||
<template x-if="!wallet.locked">
|
||||
<div>
|
||||
<label class="field-label">Destination</label>
|
||||
<textarea
|
||||
x-model="send.destination"
|
||||
placeholder="Lightning invoice / Spark address / Bitcoin address"
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
@input="detectType()"
|
||||
></textarea>
|
||||
|
||||
<div x-show="send.detectedType" class="type-badge" x-text="send.detectedType"></div>
|
||||
|
||||
<template x-if="send.detectedType !== '⚡ Lightning'">
|
||||
<div>
|
||||
<label class="field-label">Amount (sats)</label>
|
||||
<input x-model="send.amountSats" type="number" placeholder="0" class="field-input" min="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<label class="field-label">Note (optional)</label>
|
||||
<input x-model="send.description" type="text" placeholder="For coffee…" class="field-input" />
|
||||
|
||||
<div x-show="send.error" class="error-msg" x-text="send.error"></div>
|
||||
<div x-show="send.success" class="success-msg" x-text="send.success"></div>
|
||||
|
||||
<button @click="sendPayment()" class="btn-primary btn-wide" :disabled="send.sending || !send.destination">
|
||||
<span x-show="!send.sending">Send ⚡</span>
|
||||
<span x-show="send.sending">Sending…</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Receive -->
|
||||
<section x-show="tab === 'receive'" x-transition>
|
||||
<h2 class="section-title">Receive</h2>
|
||||
|
||||
<div class="receive-tabs">
|
||||
<template x-for="t in ['spark', 'lightning', 'onchain']" :key="t">
|
||||
<button
|
||||
@click="receive.tab = t; loadReceive()"
|
||||
class="receive-tab"
|
||||
:class="{ active: receive.tab === t }"
|
||||
x-text="t.charAt(0).toUpperCase() + t.slice(1)"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="receive.tab === 'spark'">
|
||||
<div class="qr-wrap"><canvas id="qr-spark"></canvas></div>
|
||||
<div class="address-copy" x-text="receive.sparkAddress" @click="copyText(receive.sparkAddress)"></div>
|
||||
<p class="hint">Tap to copy. Works for Spark-to-Spark payments.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="receive.tab === 'lightning'">
|
||||
<div x-show="wallet.locked" class="lock-notice">
|
||||
<button @click="showPinOverlay = true" class="btn-primary">Unlock to generate invoice</button>
|
||||
</div>
|
||||
<template x-if="!wallet.locked">
|
||||
<div>
|
||||
<label class="field-label">Amount (sats, leave blank for any)</label>
|
||||
<input x-model="receive.amountSats" type="number" placeholder="0" class="field-input" min="1" />
|
||||
<button @click="loadReceive()" class="btn-secondary" style="margin-top:8px">Generate Invoice</button>
|
||||
<template x-if="receive.lightningInvoice">
|
||||
<div>
|
||||
<div class="qr-wrap"><canvas id="qr-lightning"></canvas></div>
|
||||
<div class="address-copy invoice-text" x-text="receive.lightningInvoice" @click="copyText(receive.lightningInvoice)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="receive.tab === 'onchain'">
|
||||
<div x-show="wallet.locked" class="lock-notice">
|
||||
<button @click="showPinOverlay = true" class="btn-primary">Unlock to get address</button>
|
||||
</div>
|
||||
<template x-if="!wallet.locked && receive.onchainAddress">
|
||||
<div>
|
||||
<div class="qr-wrap"><canvas id="qr-onchain"></canvas></div>
|
||||
<div class="address-copy" x-text="receive.onchainAddress" @click="copyText(receive.onchainAddress)"></div>
|
||||
<p class="hint">On-chain deposits may take 1–6 confirmations.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- History -->
|
||||
<section x-show="tab === 'history'" x-transition>
|
||||
<h2 class="section-title">History</h2>
|
||||
<div x-show="history.loading" class="hint">Loading…</div>
|
||||
<div x-show="!history.loading && history.items.length === 0" class="hint">No transactions yet.</div>
|
||||
<ul class="tx-list">
|
||||
<template x-for="tx in history.items" :key="tx.id">
|
||||
<li class="tx-item">
|
||||
<div class="tx-left">
|
||||
<span class="tx-icon" x-text="txIcon(tx.status)"></span>
|
||||
<div>
|
||||
<div class="tx-amount" x-text="tx.amount_sats.toLocaleString() + ' sats'"></div>
|
||||
<div class="tx-dest" x-text="tx.recipient_address ? tx.recipient_address.slice(0, 30) + '…' : 'unknown'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-right">
|
||||
<div class="tx-status" :class="'status-' + tx.status" x-text="tx.status"></div>
|
||||
<div class="tx-date" x-text="formatDate(tx.created_at)"></div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<button x-show="history.items.length >= history.limit" @click="loadMore()" class="btn-secondary btn-wide">Load more</button>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<nav class="tab-bar">
|
||||
<button @click="tab = 'dashboard'; refresh()" class="tab-btn" :class="{ active: tab === 'dashboard' }">
|
||||
<span class="tab-icon">💼</span><span class="tab-label">Wallet</span>
|
||||
</button>
|
||||
<button @click="tab = 'send'" class="tab-btn" :class="{ active: tab === 'send' }">
|
||||
<span class="tab-icon">↑</span><span class="tab-label">Send</span>
|
||||
</button>
|
||||
<button @click="tab = 'receive'; loadReceive()" class="tab-btn" :class="{ active: tab === 'receive' }">
|
||||
<span class="tab-icon">↓</span><span class="tab-label">Receive</span>
|
||||
</button>
|
||||
<button @click="tab = 'history'; loadHistory()" class="tab-btn" :class="{ active: tab === 'history' }">
|
||||
<span class="tab-icon">🕐</span><span class="tab-label">History</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="toast" x-show="toastVisible" x-transition>Copied!</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,471 @@
|
||||
/* Telegram theme variables with fallbacks for browser testing */
|
||||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #ffffff);
|
||||
--secondary-bg: var(--tg-theme-secondary-bg-color, #f4f4f5);
|
||||
--text: var(--tg-theme-text-color, #111111);
|
||||
--hint: var(--tg-theme-hint-color, #888888);
|
||||
--link: var(--tg-theme-link-color, #2481cc);
|
||||
--btn-bg: var(--tg-theme-button-color, #2481cc);
|
||||
--btn-text: var(--tg-theme-button-text-color, #ffffff);
|
||||
--accent: #f7931a; /* Bitcoin orange */
|
||||
--radius: 12px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body.app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--secondary-bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.header-title { font-weight: 600; font-size: 16px; }
|
||||
.lock-badge {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lock-badge.locked { background: #fee; color: #c00; }
|
||||
.lock-badge.unlocked { background: #efe; color: #060; }
|
||||
|
||||
/* ── Content ─────────────────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 16px calc(70px + var(--safe-bottom));
|
||||
}
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Balance card ────────────────────────────────────────────────────── */
|
||||
.balance-card {
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-text);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.balance-label { font-size: 13px; opacity: .8; margin-bottom: 6px; }
|
||||
.balance-amount {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.balance-unit { font-size: 18px; font-weight: 400; margin-left: 4px; }
|
||||
.balance-btc { font-size: 13px; opacity: .7; margin-top: 4px; }
|
||||
.unlock-prompt { margin-top: 16px; }
|
||||
|
||||
/* ── Quick actions ───────────────────────────────────────────────────── */
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.quick-btn {
|
||||
background: var(--secondary-bg);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.quick-btn:active { opacity: .7; }
|
||||
.quick-icon { font-size: 22px; }
|
||||
|
||||
/* ── Address row ─────────────────────────────────────────────────────── */
|
||||
.address-row {
|
||||
background: var(--secondary-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.address-label { font-size: 11px; color: var(--hint); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.address-value {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
/* ── Session info ────────────────────────────────────────────────────── */
|
||||
.session-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hint { font-size: 12px; color: var(--hint); }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-text);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 13px 22px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary:active:not(:disabled) { opacity: .85; }
|
||||
.btn-secondary {
|
||||
background: var(--secondary-bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-sm { padding: 7px 14px; font-size: 13px; }
|
||||
.btn-wide { width: 100%; margin-top: 16px; }
|
||||
|
||||
/* ── Form fields ─────────────────────────────────────────────────────── */
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--hint);
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.field-input, .field-textarea {
|
||||
width: 100%;
|
||||
background: var(--secondary-bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
.field-textarea { line-height: 1.4; }
|
||||
.type-badge {
|
||||
font-size: 12px;
|
||||
color: var(--hint);
|
||||
margin: 6px 0 0 2px;
|
||||
}
|
||||
.error-msg { color: #c00; font-size: 13px; margin-top: 10px; }
|
||||
.success-msg { color: #060; font-size: 13px; margin-top: 10px; }
|
||||
|
||||
/* ── Lock notice ─────────────────────────────────────────────────────── */
|
||||
.lock-notice {
|
||||
background: var(--secondary-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.lock-notice p { color: var(--hint); margin-bottom: 14px; }
|
||||
|
||||
/* ── Receive tabs ────────────────────────────────────────────────────── */
|
||||
.receive-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.receive-tab {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--secondary-bg);
|
||||
color: var(--hint);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.receive-tab.active {
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── QR ──────────────────────────────────────────────────────────────── */
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 16px 0;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.address-copy {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
background: var(--secondary-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--link);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.invoice-text { font-size: 10px; }
|
||||
|
||||
/* ── Transaction list ────────────────────────────────────────────────── */
|
||||
.tx-list { list-style: none; }
|
||||
.tx-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--secondary-bg);
|
||||
}
|
||||
.tx-left { display: flex; align-items: center; gap: 12px; }
|
||||
.tx-icon { font-size: 20px; width: 28px; text-align: center; }
|
||||
.tx-amount { font-weight: 600; font-size: 15px; }
|
||||
.tx-dest { font-size: 12px; color: var(--hint); margin-top: 2px; font-family: monospace; }
|
||||
.tx-right { text-align: right; }
|
||||
.tx-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
.status-done { background: #efe; color: #060; }
|
||||
.status-failed { background: #fee; color: #c00; }
|
||||
.status-processing { background: #fef; color: #606; }
|
||||
.status-expired { background: var(--secondary-bg); color: var(--hint); }
|
||||
.status-awaiting_unlock { background: #ffe; color: #660; }
|
||||
.tx-date { font-size: 11px; color: var(--hint); margin-top: 4px; }
|
||||
|
||||
/* ── Bottom tab bar ──────────────────────────────────────────────────── */
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--secondary-bg);
|
||||
display: flex;
|
||||
padding-bottom: var(--safe-bottom);
|
||||
border-top: 1px solid rgba(0,0,0,.08);
|
||||
z-index: 20;
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px 4px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--hint);
|
||||
transition: color .15s;
|
||||
}
|
||||
.tab-btn.active { color: var(--btn-bg); }
|
||||
.tab-icon { font-size: 20px; }
|
||||
.tab-label { font-size: 11px; font-weight: 500; }
|
||||
|
||||
/* ── PIN overlay ─────────────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
.pin-card {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 28px 24px calc(24px + var(--safe-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.pin-title { font-size: 20px; font-weight: 700; text-align: center; }
|
||||
.pin-hint { font-size: 14px; color: var(--hint); text-align: center; }
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
background: var(--secondary-bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
letter-spacing: 4px;
|
||||
outline: none;
|
||||
}
|
||||
.pin-error { font-size: 13px; color: #c00; text-align: center; }
|
||||
|
||||
/* ── Wallet UI wrapper (hides during setup/seed) ─────────────────────── */
|
||||
.wallet-ui {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── Setup screen ────────────────────────────────────────────────────── */
|
||||
.setup-screen {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 20px calc(24px + var(--safe-bottom));
|
||||
}
|
||||
.setup-inner {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.setup-icon {
|
||||
font-size: 52px;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.setup-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.setup-sub {
|
||||
font-size: 14px;
|
||||
color: var(--hint);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Seed reveal screen ──────────────────────────────────────────────── */
|
||||
.seed-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
z-index: 300;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px calc(32px + var(--safe-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.seed-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hint);
|
||||
font-size: 15px;
|
||||
}
|
||||
.seed-error-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.seed-error-icon { font-size: 48px; }
|
||||
.seed-error-msg { color: #c00; font-size: 15px; }
|
||||
.seed-content { display: flex; flex-direction: column; gap: 20px; }
|
||||
.seed-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
.seed-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--hint);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.seed-subtitle strong { color: #c00; }
|
||||
.seed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.seed-word {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--secondary-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.seed-num {
|
||||
font-size: 11px;
|
||||
color: var(--hint);
|
||||
min-width: 18px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.seed-val {
|
||||
font-family: monospace;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.seed-confirm-wrap {
|
||||
background: var(--secondary-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.seed-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seed-check input { margin-top: 2px; width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: calc(75px + var(--safe-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,.8);
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
Reference in New Issue
Block a user