diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ac3f4f2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,28 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Typecheck
+ run: pnpm typecheck
+
+ - name: E2E tests
+ run: pnpm test
diff --git a/.gitignore b/.gitignore
index 675b123..9e5611a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,9 +6,13 @@ node_modules
dist
enclave.eif
+# Wrangler
+.wrangler
+
# Environment & secrets
.env*
*.pem
+api/api-data/
# Logs
*.log
@@ -23,4 +27,5 @@ Thumbs.db
.vscode
-.claude
\ No newline at end of file
+.claude
+branding/
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1097fbe
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,11 @@
+# Project Rules
+
+## Core Principle
+
+Agents hold Bitcoin (sats) as their treasury. When an agent needs to pay for something — an x402 API, a stablecoin transfer, any fiat-denominated cost — it swaps BTC to stablecoins on the fly via the existing swap infrastructure (LendaSwap + Boltz). The agent never holds stablecoins as a reserve. Bitcoin in, stablecoins out, on demand.
+
+This applies to all payment integrations: x402, direct stablecoin sends, Lightning invoices. The CLI command `cash send --currency usdc --where polygon --to
` already handles the BTC→stablecoin atomic swap. New payment protocols (like x402) should build on this, not bypass it.
+
+## Landing Page
+
+When editing `landing-page/index.html`, always update `landing-page/index.md` to reflect the same content. The markdown file is the machine-readable version served to LLMs and agents via ``. Keep both files in sync.
diff --git a/README.md b/README.md
index 4eff29a..ac43387 100644
--- a/README.md
+++ b/README.md
@@ -1,49 +1,233 @@
-# clw.cash MVP
+# clw.cash
-Backend scaffold for key management with create/sign/destroy flows where signing is delegated to an enclave service.
+Privy, for AI Agents.
+
+Secure identity infrastructure that lets AI agents hold, sign, and transact with Bitcoin and stablecoins. Private keys live inside hardware enclaves — your agent gets a simple CLI, never touches the raw key material.
+
+## Vision: Agents Hold Sats, Pay the World
+
+Agents accumulate and hold Bitcoin (sats). When they need to pay for something — an API behind a paywall, a stablecoin transfer, an x402-protected resource — they swap BTC to stablecoins on the fly and send. The agent never needs to hold stablecoins as a reserve; Bitcoin is the treasury, stablecoins are the payment rail.
+
+This means clw.cash is **x402-compatible by design**. When an agent hits a `402 Payment Required` response demanding USDC on Polygon, it already has everything it needs: `cash send --amount 10 --currency usdc --where polygon --to ` swaps BTC→USDC atomically and delivers the payment. The swap infrastructure (LendaSwap + Boltz) handles the cross-chain bridge. The agent just says "pay X in currency Y" and it works.
+
+## How it works
+
+```
+Agent ──► cash CLI ──► skills/ ──► sdk/ ──► clw.cash API ──► Enclave (secp256k1)
+ │
+ └── audit log, rate limits, 2FA via Telegram
+```
## Layout
-- `api/`: external API service
-- `enclave/`: enclave signer service (Dockerized HTTP app)
-- `schemas/`: OpenAPI + JSON schemas
-- `infra/`: enclave config and deployment notes
-- `docs/`: runbook and threat model
+```
+api/ Public-facing REST API (auth, identities, signing)
+enclave/ Signer service (runs inside Evervault Enclave)
+sdk/ TypeScript SDK — RemoteSignerIdentity, API client, signing utils
+skills/ Bitcoin, Lightning, and Stablecoin skills (Ark, Boltz, LendaSwap)
+cli/ Agent-friendly CLI ("cash") — send, receive, balance
+schemas/ OpenAPI + JSON schemas
+infra/ Enclave config and deployment
+```
+
+## CLI — `cash`
+
+```bash
+npm i -g claw-cash
+```
+
+The CLI outputs JSON to stdout, designed to be called by AI agents as a subprocess tool. Full command reference and agent tips: [SKILL.md](https://clw.cash/SKILL.md).
-## Local quickstart
+## Quickstart
```bash
pnpm install
-pnpm --filter ./enclave start
-pnpm --filter ./api start
```
-API defaults to `http://127.0.0.1:4000`, enclave defaults to `http://127.0.0.1:7000`.
+### 1. Start services locally
-## Evervault Enclave Deployment
+```bash
+# Terminal 1 — enclave signer (runs on :7000)
+pnpm start:enclave
+
+# Terminal 2 — API (runs on :4000)
+pnpm start:api
+```
+
+No enclave redeploy needed for local development. The enclave service runs as a regular Node process locally — it only runs inside Evervault in production.
+
+### 2. Initialize the CLI
+
+```bash
+# Auto-authenticates, creates an identity, saves config, starts daemon
+pnpm --filter ./cli dev -- init \
+ --api-url http://127.0.0.1:4000 \
+ --ark-server https://server.arkade.fun
+```
-### Prerequisites
+In test mode (no `TELEGRAM_BOT_TOKEN` set), authentication resolves automatically. In production, a Telegram deep link is shown for 2FA confirmation.
-Install the [Evervault CLI](https://docs.evervault.com/cli).
+This creates `~/.clw-cash/config.json` with your identity credentials and starts a background daemon for monitoring swaps (Lightning HTLC claiming and LendaSwap polling).
-### Initialize (one-time)
+You can also pass a token explicitly: `--token `.
-Generate signing certificates:
+### 3. Use the CLI
```bash
-ev enclave cert new --output ./infra
+# Check balance (requires Ark server to be reachable)
+pnpm --filter ./cli dev -- balance
+
+# Receive — get an Ark address
+pnpm --filter ./cli dev -- receive --amount 100000 --currency btc --where arkade
+
+# Receive — create a Lightning invoice
+pnpm --filter ./cli dev -- receive --amount 50000 --currency btc --where lightning
```
-### Build
+You can also set env vars instead of using the config file:
```bash
-ev enclave build -v --output . -c ./infra/enclave.toml ./enclave
+export CLW_API_URL=http://127.0.0.1:4000
+export CLW_SESSION_TOKEN=
+export CLW_IDENTITY_ID=
+export CLW_PUBLIC_KEY=
+export CLW_ARK_SERVER_URL=https://server.arkade.fun
+```
+
+## Testing
+
+### E2E tests (API + Enclave only)
+
+```bash
+pnpm test:e2e
+```
+
+This spins up the enclave and API on random ports, runs the full user journey (auth, identity, sign, destroy, backup/restore), and tears down. No external dependencies needed.
+
+### Typecheck all packages
+
+```bash
+pnpm typecheck
+```
+
+## API Endpoints
+
+| Method | Path | Auth | Description |
+| ------ | ---- | ---- | ----------- |
+| GET | `/health` | No | Health check |
+| POST | `/v1/auth/challenge` | No | Create auth challenge |
+| POST | `/v1/auth/verify` | No | Verify challenge, get JWT |
+| POST | `/v1/auth/bot-session` | Bot key | Get session for a Telegram user (bot-to-bot) |
+| POST | `/v1/identities` | Yes | Create identity (key generated in enclave) |
+| POST | `/v1/identities/:id/restore` | Yes | Restore identity from backup |
+| POST | `/v1/identities/:id/sign-intent` | Yes | Get signing ticket |
+| POST | `/v1/identities/:id/sign` | Yes | Sign with ticket |
+| POST | `/v1/identities/:id/sign-batch` | Yes | Batch sign multiple digests |
+| DELETE | `/v1/identities/:id` | Yes | Destroy identity |
+| GET | `/v1/audit` | Yes | Audit trail |
+
+## Bot Integration (Factory Bot)
+
+clw.cash acts as a **factory bot** — a backend service that other Telegram bots use to give their users Bitcoin wallets. Your bot authenticates with a shared API key and gets per-user sessions without any user-facing auth flow.
+
+### How it works
+
+```text
+User (Telegram) Your Bot clw.cash API Enclave
+ │ │ │ │
+ │ "send 1000 sats" │ │ │
+ │ from.id = 98765 │ │ │
+ │──────────────────────►│ │ │
+ │ │ POST /v1/auth/bot-session │ │
+ │ │ x-bot-api-key: │ │
+ │ │ { telegram_user_id: 98765 } │
+ │ │───────────────────────────►│ │
+ │ │ ◄── { token, user } │ │
+ │ │ │ │
+ │ │ SDK: wallet.sendBitcoin() │ sign digest │
+ │ │───────────────────────────►│───────────────────►│
+ │ │ ◄── { txid } │ ◄── { signature } │
+ │ ◄── "Sent!" │ │ │
+```
+
+**Telegram guarantees `from.id` can't be faked** — only your bot (with the API key) can create sessions, and it only does so for verified Telegram users. No impersonation is possible.
+
+### Configuration
+
+1. **Create a Telegram bot** via [@BotFather](https://t.me/BotFather) — this is the "factory" auth bot
+1. **Generate a bot API key** — any random secret string
+1. **Set env vars** on the clw.cash API server:
+
+```bash
+TELEGRAM_BOT_TOKEN=
+TELEGRAM_BOT_USERNAME=
+BOT_API_KEY=
```
-This produces `enclave.eif`.
+1. **In your bot code**, use the SDK directly (not the CLI):
+
+```typescript
+import { createClwBitcoinSkill } from "@clw-cash/skills";
+
+// Get a session for this Telegram user
+const session = await fetch("https://api.clw.cash/v1/auth/bot-session", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-bot-api-key": process.env.BOT_API_KEY,
+ },
+ body: JSON.stringify({ telegram_user_id: String(msg.from.id) }),
+}).then(r => r.json());
+
+// Create a wallet skill for this user
+const bitcoin = await createClwBitcoinSkill({
+ apiBaseUrl: "https://api.clw.cash",
+ sessionToken: session.token,
+ identityId: user.identityId,
+ publicKey: user.publicKey,
+ arkServerUrl: "https://server.arkade.fun",
+});
+
+// Use it
+const result = await bitcoin.send({ address: "ark1q...", amount: 1000 });
+```
+
+### Auth modes
+
+| Mode | How it works | Use case |
+| ---- | ------------ | -------- |
+| **CLI** (`cash init`) | Challenge → Telegram deep link → human confirms | Developer testing, standalone agent |
+| **Bot session** | Bot API key + `telegram_user_id` → instant JWT | Telegram bot serving many users |
+| **Test mode** | No `TELEGRAM_BOT_TOKEN` → auto-resolves | Local dev, CI |
+
+## Deploy to Evervault
-### Deploy
+Install the [Evervault CLI](https://docs.evervault.com/cli), then:
```bash
+# one-time: generate signing certs
+ev enclave cert new --output ./infra
+
+# build enclave image
+ev enclave build -v --output . -c ./infra/enclave.toml ./enclave
+
+# deploy
ev enclave deploy -v --eif-path ./enclave.eif -c ./infra/enclave.toml
```
+
+## Roadmap
+
+### Now
+
+- [ ] **MCP server** — Claude Code / Claude Desktop tool-use integration
+
+### Next
+
+- [ ] **x402 client support** — `cash pay ` command, auto-swap BTC→stablecoin, retry with proof. Blocked on ECDSA signing in enclave ([#5](https://github.com/tiero/clw.cash/issues/5))
+- [ ] **Spending policies** — per-agent limits, allowlists, time-based rules, enforced at enclave level
+- [ ] **More auth providers** — Slack, Google, 1Password, YubiKey, Passkeys
+
+### Later
+
+- [ ] **Persistent storage** — replace in-memory store with PostgreSQL
+- [ ] **Webhook notifications** — push events for transaction completion, swap settlement, balance changes
diff --git a/api/README.md b/api/README.md
index b0b4fbb..43d60b5 100644
--- a/api/README.md
+++ b/api/README.md
@@ -1,18 +1,59 @@
# API Service
-External API service for user/session management, wallet metadata, ticket issuance, audit logs, and orchestration of enclave key operations.
+External API service for user/session management, identity metadata, ticket issuance, audit logs, and orchestration of enclave key operations.
## Endpoints
-- `POST /v1/users`
-- `POST /v1/sessions`
-- `POST /v1/wallets`
-- `POST /v1/wallets/:id/sign-intent`
-- `POST /v1/wallets/:id/sign`
-- `DELETE /v1/wallets/:id`
+- `POST /v1/users` — create or get user (returns `confirm_token` if pending)
+- `POST /v1/users/confirm` — confirm user account with token
+- `POST /v1/sessions` — create session (requires confirmed user)
+- `GET /v1/identities` — list active identities for authenticated user
+- `POST /v1/identities`
+- `POST /v1/identities/:id/restore` — restore identity from sealed backup
+- `POST /v1/identities/:id/sign-intent`
+- `POST /v1/identities/:id/sign`
+- `DELETE /v1/identities/:id`
- `GET /v1/audit`
- `GET /health`
+## User confirmation flow
+
+New users start with `status: "pending"` and must confirm before they can create sessions.
+
+```text
+1. POST /v1/users { "telegram_user_id": "123" }
+ → 201 { id, telegram_user_id, status: "pending", confirm_token: "" }
+
+2. Bot/CLI presents the confirm_token to the user via Telegram
+
+3. POST /v1/users/confirm { "telegram_user_id": "123", "confirm_token": "" }
+ → 200 { id, telegram_user_id, status: "active" }
+
+4. POST /v1/sessions { "telegram_user_id": "123" }
+ → 200 { token, expires_in } (only works for active users)
+```
+
+## Agent Tips
+
+All endpoints return JSON. Use `curl` + `jq` to extract specific fields:
+
+```bash
+# Check if API is healthy
+curl -s https://api.clw.cash/health | jq .status
+
+# Create a user and extract confirm_token
+curl -s -X POST https://api.clw.cash/v1/users \
+ -H "Content-Type: application/json" \
+ -d '{"telegram_user_id": "123"}' | jq -r .confirm_token
+
+# Create a session and extract the JWT
+curl -s -X POST https://api.clw.cash/v1/sessions \
+ -H "Content-Type: application/json" \
+ -d '{"telegram_user_id": "123"}' | jq -r .token
+```
+
+Note: The CLI (`cash init`, `cash login`) handles the full auth flow automatically. Direct API calls are only needed for custom integrations.
+
## Local run
```bash
diff --git a/api/migrations/0001_create_schema.sql b/api/migrations/0001_create_schema.sql
new file mode 100644
index 0000000..6d4ceb4
--- /dev/null
+++ b/api/migrations/0001_create_schema.sql
@@ -0,0 +1,43 @@
+-- Users
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ telegram_user_id TEXT NOT NULL UNIQUE,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('pending', 'active')),
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_user_id);
+
+-- Identities
+CREATE TABLE IF NOT EXISTS identities (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id),
+ alg TEXT NOT NULL CHECK (alg IN ('secp256k1')),
+ public_key TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'destroyed')),
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_identities_user ON identities(user_id);
+
+-- Audit events
+CREATE TABLE IF NOT EXISTS audit_events (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id),
+ identity_id TEXT,
+ action TEXT NOT NULL,
+ metadata TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_events(user_id);
+CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_events(created_at DESC);
+
+-- Key backups
+CREATE TABLE IF NOT EXISTS key_backups (
+ identity_id TEXT PRIMARY KEY,
+ alg TEXT NOT NULL,
+ sealed_key TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
diff --git a/api/migrations/0002_challenges_table.sql b/api/migrations/0002_challenges_table.sql
new file mode 100644
index 0000000..7a63c47
--- /dev/null
+++ b/api/migrations/0002_challenges_table.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS challenges (
+ id TEXT PRIMARY KEY,
+ telegram_user_id TEXT,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
diff --git a/api/package.json b/api/package.json
index 45c52b2..6c26e9a 100644
--- a/api/package.json
+++ b/api/package.json
@@ -1,24 +1,24 @@
{
- "name": "claw-cash-api",
- "version": "0.1.0",
+ "name": "clw-cash-api",
+ "version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
- "start": "tsx src/index.ts",
- "dev": "tsx watch src/index.ts",
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy",
+ "deploy:prod": "wrangler deploy --env production",
+ "db:migrate:local": "wrangler d1 migrations apply clw-cash-db --local",
+ "db:migrate:prod": "wrangler d1 migrations apply clw-cash-db --env production --remote",
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "express": "^4.21.2",
- "jsonwebtoken": "^9.0.2",
- "uuid": "^11.1.0",
+ "hono": "^4.7.0",
+ "jose": "^6.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
- "@types/express": "^4.17.21",
- "@types/jsonwebtoken": "^9.0.7",
- "@types/node": "^22.13.4",
- "tsx": "^4.19.3",
- "typescript": "^5.7.3"
+ "@cloudflare/workers-types": "^4.20250214.0",
+ "typescript": "^5.7.3",
+ "wrangler": "^4.65.0"
}
}
diff --git a/api/src/auth.ts b/api/src/auth.ts
index 7b9dd5e..a9b7565 100644
--- a/api/src/auth.ts
+++ b/api/src/auth.ts
@@ -1,29 +1,46 @@
-import jwt from "jsonwebtoken";
-import { config } from "./config.js";
+import { SignJWT, jwtVerify } from "jose";
import type { SessionClaims, TicketClaims } from "./types.js";
-export const signSessionToken = (claims: SessionClaims): string => {
- return jwt.sign(claims, config.sessionSigningSecret, {
- algorithm: "HS256",
- expiresIn: config.sessionTtlSeconds
- });
-};
+const encoder = new TextEncoder();
-export const verifySessionToken = (token: string): SessionClaims => {
- return jwt.verify(token, config.sessionSigningSecret, {
- algorithms: ["HS256"]
- }) as SessionClaims;
+export const signSessionToken = async (
+ claims: SessionClaims,
+ secret: string,
+ ttlSeconds: number,
+): Promise => {
+ return new SignJWT(claims as unknown as Record)
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime(`${ttlSeconds}s`)
+ .sign(encoder.encode(secret));
};
-export const signTicketToken = (claims: TicketClaims): string => {
- return jwt.sign(claims, config.ticketSigningSecret, {
- algorithm: "HS256",
- expiresIn: config.ticketTtlSeconds
+export const verifySessionToken = async (
+ token: string,
+ secret: string,
+): Promise => {
+ const { payload } = await jwtVerify(token, encoder.encode(secret), {
+ algorithms: ["HS256"],
});
+ return payload as unknown as SessionClaims;
};
-export const verifyTicketToken = (token: string): TicketClaims => {
- return jwt.verify(token, config.ticketSigningSecret, {
- algorithms: ["HS256"]
- }) as TicketClaims;
+export const signTicketToken = async (
+ claims: TicketClaims,
+ secret: string,
+ ttlSeconds: number,
+): Promise => {
+ return new SignJWT(claims as unknown as Record)
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime(`${ttlSeconds}s`)
+ .sign(encoder.encode(secret));
+};
+
+export const verifyTicketToken = async (
+ token: string,
+ secret: string,
+): Promise => {
+ const { payload } = await jwtVerify(token, encoder.encode(secret), {
+ algorithms: ["HS256"],
+ });
+ return payload as unknown as TicketClaims;
};
diff --git a/api/src/bindings.ts b/api/src/bindings.ts
new file mode 100644
index 0000000..ad13663
--- /dev/null
+++ b/api/src/bindings.ts
@@ -0,0 +1,27 @@
+export interface Env {
+ // D1 Database
+ DB: D1Database;
+
+ // KV Namespaces
+ KV_TICKETS: KVNamespace;
+ KV_RATE_LIMIT: KVNamespace;
+
+ // Secrets
+ INTERNAL_API_KEY: string;
+ TICKET_SIGNING_SECRET: string;
+ SESSION_SIGNING_SECRET: string;
+ TELEGRAM_BOT_TOKEN: string;
+ TELEGRAM_BOT_USERNAME: string;
+ EV_API_KEY: string;
+
+ // Variables
+ ALLOW_TEST_AUTH: string;
+ ENCLAVE_BASE_URL: string;
+ TICKET_TTL_SECONDS: string;
+ SESSION_TTL_SECONDS: string;
+ CHALLENGE_TTL_SECONDS: string;
+ RATE_LIMIT_WINDOW_MS: string;
+ RATE_LIMIT_PER_USER: string;
+ RATE_LIMIT_PER_IDENTITY_SIGN: string;
+ ALLOWED_ORIGINS: string;
+}
diff --git a/api/src/config.ts b/api/src/config.ts
deleted file mode 100644
index 4c168bd..0000000
--- a/api/src/config.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-const parseBoolean = (value: string | undefined, fallback: boolean): boolean => {
- if (value === undefined) {
- return fallback;
- }
- return value.toLowerCase() === "true";
-};
-
-const parseNumber = (value: string | undefined, fallback: number): number => {
- if (value === undefined || value.trim().length === 0) {
- return fallback;
- }
- const parsed = Number(value);
- if (!Number.isFinite(parsed) || parsed <= 0) {
- return fallback;
- }
- return parsed;
-};
-
-export const config = {
- port: parseNumber(process.env.API_PORT, 4000),
- enclaveBaseUrl: process.env.ENCLAVE_BASE_URL ?? "http://127.0.0.1:7000",
- internalApiKey: process.env.INTERNAL_API_KEY ?? "change-me-in-production",
- ticketSigningSecret: process.env.TICKET_SIGNING_SECRET ?? "ticket-secret-dev-only",
- sessionSigningSecret: process.env.SESSION_SIGNING_SECRET ?? "session-secret-dev-only",
- ticketTtlSeconds: parseNumber(process.env.TICKET_TTL_SECONDS, 90),
- sessionTtlSeconds: parseNumber(process.env.SESSION_TTL_SECONDS, 3600),
- requireOtp: parseBoolean(process.env.REQUIRE_OTP, false),
- validOtpCodes: (process.env.VALID_OTP_CODES ?? "").split(",").map((v) => v.trim()).filter(Boolean),
- backupFilePath: process.env.BACKUP_FILE_PATH ?? "./api-data/key-backups.json",
- rateLimitWindowMs: parseNumber(process.env.RATE_LIMIT_WINDOW_MS, 60_000),
- rateLimitPerUser: parseNumber(process.env.RATE_LIMIT_PER_USER, 60),
- rateLimitPerWalletSign: parseNumber(process.env.RATE_LIMIT_PER_WALLET_SIGN, 20)
-};
diff --git a/api/src/enclaveClient.ts b/api/src/enclaveClient.ts
index 9ea4e67..b40d6e8 100644
--- a/api/src/enclaveClient.ts
+++ b/api/src/enclaveClient.ts
@@ -6,6 +6,9 @@ interface GenerateResponse {
interface SignResponse {
signature: string;
+ r?: string;
+ s?: string;
+ v?: number;
}
interface DestroyResponse {
@@ -14,7 +17,7 @@ interface DestroyResponse {
interface ExportKeyResponse {
alg: SupportedAlg;
- private_key: string;
+ sealed_key: string;
}
type JsonMap = Record;
@@ -22,65 +25,74 @@ type JsonMap = Record;
export class EnclaveClient {
constructor(
private readonly baseUrl: string,
- private readonly internalApiKey: string
+ private readonly internalApiKey: string,
+ private readonly evApiKey?: string
) {}
- async generate(walletId: string, alg: SupportedAlg): Promise {
+ async generate(identityId: string, alg: SupportedAlg): Promise {
return this.request("/internal/generate", {
- wallet_id: walletId,
+ identity_id: identityId,
alg
});
}
- async sign(walletId: string, digest: string, ticket: string): Promise {
+ async sign(identityId: string, digest: string, ticket: string, signatureType: "schnorr" | "ecdsa" = "schnorr"): Promise {
return this.request("/internal/sign", {
- wallet_id: walletId,
+ identity_id: identityId,
digest,
- ticket
+ ticket,
+ signature_type: signatureType
});
}
- async destroy(walletId: string): Promise {
+ async destroy(identityId: string): Promise {
return this.request("/internal/destroy", {
- wallet_id: walletId
+ identity_id: identityId
});
}
- async exportKey(walletId: string): Promise {
+ async exportKey(identityId: string): Promise {
return this.request("/internal/backup/export", {
- wallet_id: walletId
+ identity_id: identityId
});
}
- async importKey(walletId: string, alg: SupportedAlg, privateKey: string): Promise<{ ok: true }> {
+ async importKey(identityId: string, alg: SupportedAlg, sealedKey: string): Promise<{ ok: true }> {
return this.request<{ ok: true }>("/internal/backup/import", {
- wallet_id: walletId,
+ identity_id: identityId,
alg,
- private_key: privateKey
+ sealed_key: sealedKey
});
}
private async request(path: string, body: JsonMap): Promise {
+ const headers: Record = {
+ "content-type": "application/json",
+ "x-internal-api-key": this.internalApiKey
+ };
+ if (this.evApiKey) {
+ headers["api-key"] = this.evApiKey;
+ }
+
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
- headers: {
- "content-type": "application/json",
- "x-internal-api-key": this.internalApiKey
- },
+ headers,
body: JSON.stringify(body)
});
+ const text = await response.text();
+
if (!response.ok) {
let details = "";
try {
- const parsed = (await response.json()) as { error?: string };
+ const parsed = JSON.parse(text) as { error?: string };
details = parsed.error ?? JSON.stringify(parsed);
} catch {
- details = await response.text();
+ details = text;
}
throw new EnclaveClientError(response.status, details || "Unknown enclave error");
}
- return (await response.json()) as T;
+ return JSON.parse(text) as T;
}
}
diff --git a/api/src/index.ts b/api/src/index.ts
index 725858d..e7c1e51 100644
--- a/api/src/index.ts
+++ b/api/src/index.ts
@@ -1,363 +1,444 @@
-import express, { type NextFunction, type Request, type Response } from "express";
-import jwt from "jsonwebtoken";
-import { v4 as uuidv4 } from "uuid";
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { HTTPException } from "hono/http-exception";
import { z } from "zod";
-import { signSessionToken, signTicketToken, verifySessionToken, verifyTicketToken } from "./auth.js";
-import { config } from "./config.js";
+import type { Env } from "./bindings.js";
+import { CloudflareStore } from "./store.js";
+import { KVRateLimiter } from "./rateLimit.js";
import { EnclaveClient, EnclaveClientError } from "./enclaveClient.js";
-import { SlidingWindowRateLimiter } from "./rateLimit.js";
-import { InMemoryStore } from "./store.js";
-import type { SessionClaims, SupportedAlg, Wallet } from "./types.js";
+import { signSessionToken, signTicketToken, verifySessionToken, verifyTicketToken } from "./auth.js";
import {
- createSessionSchema,
- createUserSchema,
- createWalletSchema,
- normalizeDigestHex,
- paginationSchema,
+ challengeRequestSchema,
+ verifySchema,
+ createIdentitySchema,
signIntentSchema,
- signSchema
+ signSchema,
+ signBatchSchema,
+ paginationSchema,
+ normalizeDigestHex,
} from "./validation.js";
+import type { SessionClaims, SupportedAlg, Identity } from "./types.js";
+
+type HonoEnv = { Bindings: Env; Variables: { auth: SessionClaims } };
+
+const app = new Hono();
+
+// ── CORS ──────────────────────────────────────────────────────
+
+app.use("/v1/*", async (c, next) => {
+ const allowedOrigins = c.env.ALLOWED_ORIGINS.split(",").map((s) => s.trim());
+ return cors({
+ origin: (origin) => {
+ if (allowedOrigins.includes(origin)) return origin;
+ return "";
+ },
+ allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
+ allowHeaders: ["Content-Type", "Authorization"],
+ })(c, next);
+});
+
+// ── Helpers ───────────────────────────────────────────────────
+
+function getStore(env: Env): CloudflareStore {
+ return new CloudflareStore(env.DB, env.KV_TICKETS, parseInt(env.CHALLENGE_TTL_SECONDS, 10));
+}
+
+function getLimiter(env: Env): KVRateLimiter {
+ return new KVRateLimiter(env.KV_RATE_LIMIT);
+}
+
+function getEnclave(env: Env): EnclaveClient {
+ return new EnclaveClient(env.ENCLAVE_BASE_URL, env.INTERNAL_API_KEY, env.EV_API_KEY || undefined);
+}
+
+async function currentUser(store: CloudflareStore, userId: string) {
+ const user = await store.getUserById(userId);
+ if (!user) throw new HTTPException(401, { message: "Session user no longer exists" });
+ return { id: user.id, telegram_user_id: user.telegram_user_id };
+}
-type AuthenticatedRequest = Request & { auth: SessionClaims };
+async function ownedActiveIdentity(store: CloudflareStore, identityId: string, userId: string): Promise {
+ const identity = await store.getIdentity(identityId);
+ if (!identity) throw new HTTPException(404, { message: "Identity not found" });
+ if (identity.user_id !== userId) throw new HTTPException(403, { message: "Identity does not belong to session user" });
+ if (identity.status !== "active") throw new HTTPException(409, { message: "Identity is not active" });
+ return identity;
+}
+
+async function enforceRate(limiter: KVRateLimiter, key: string, env: Env): Promise {
+ const limit = key.includes(":sign") && !key.includes("sign_intent")
+ ? parseInt(env.RATE_LIMIT_PER_IDENTITY_SIGN, 10)
+ : parseInt(env.RATE_LIMIT_PER_USER, 10);
+ const ok = await limiter.allow(key, limit, parseInt(env.RATE_LIMIT_WINDOW_MS, 10));
+ if (!ok) throw new HTTPException(429, { message: "Rate limit exceeded" });
+}
-const app = express();
-app.use(express.json({ limit: "32kb" }));
+function stripWrappingQuotes(s: string): string {
+ if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1);
+ return s;
+}
-const store = new InMemoryStore(config.backupFilePath);
-const enclaveClient = new EnclaveClient(config.enclaveBaseUrl, config.internalApiKey);
-const limiter = new SlidingWindowRateLimiter();
+async function restoreBackup(store: CloudflareStore, enclave: EnclaveClient, identityId: string): Promise {
+ const backup = await store.getBackup(identityId);
+ if (!backup) return false;
+ await enclave.importKey(identityId, backup.alg, stripWrappingQuotes(backup.sealed_key));
+ return true;
+}
-const requireAuth = (req: Request, res: Response, next: NextFunction): void => {
- const authHeader = req.header("authorization") ?? "";
- if (!authHeader.startsWith("Bearer ")) {
- res.status(401).json({ error: "Missing bearer token" });
- return;
+async function sendTelegramMessage(botToken: string, chatId: number, text: string): Promise {
+ try {
+ await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ chat_id: chatId, text }),
+ });
+ } catch {
+ console.error("[telegram] sendMessage failed");
}
- const token = authHeader.slice("Bearer ".length);
+}
+
+// ── Auth middleware ───────────────────────────────────────────
+
+const requireAuth = async (c: any, next: any) => {
+ const header = c.req.header("authorization") ?? "";
+ if (!header.startsWith("Bearer ")) throw new HTTPException(401, { message: "Missing bearer token" });
try {
- const claims = verifySessionToken(token);
- (req as AuthenticatedRequest).auth = claims;
- next();
+ const claims = await verifySessionToken(header.slice(7), c.env.SESSION_SIGNING_SECRET);
+ c.set("auth", claims);
+ await next();
} catch {
- res.status(401).json({ error: "Invalid session token" });
+ throw new HTTPException(401, { message: "Invalid session token" });
}
};
-const parse = (schema: T, source: unknown): z.output => {
- return schema.parse(source);
-};
+// ── Routes ────────────────────────────────────────────────────
-const parsePagination = (req: Request): { limit: number; offset: number } => {
- return parse(paginationSchema, req.query);
-};
+app.get("/health", (c) => c.json({ ok: true, service: "api" }));
-const currentUserFromRequest = (req: AuthenticatedRequest): { id: string; telegram_user_id: string } => {
- const user = store.getUserById(req.auth.sub);
- if (!user) {
- throw new ApiError(401, "Session user no longer exists");
+// Swap proxy (CORS workaround for web UI)
+app.get("/v1/swaps/:id", async (c) => {
+ const id = c.req.param("id");
+ const upstream = await fetch(`https://apilendaswap.lendasat.com/swap/${encodeURIComponent(id)}`);
+ if (!upstream.ok) {
+ const text = await upstream.text();
+ throw new HTTPException(upstream.status === 400 ? 404 : (upstream.status as any), {
+ message: text || "Swap not found",
+ });
}
- return { id: user.id, telegram_user_id: user.telegram_user_id };
-};
+ return c.json(await upstream.json());
+});
-const requireOwnedActiveWallet = (walletId: string, userId: string): Wallet => {
- const wallet = store.getWallet(walletId);
- if (!wallet) {
- throw new ApiError(404, "Wallet not found");
- }
- if (wallet.user_id !== userId) {
- throw new ApiError(403, "Wallet does not belong to session user");
- }
- if (wallet.status !== "active") {
- throw new ApiError(409, "Wallet is not active");
- }
- return wallet;
-};
+// ── Auth ──────────────────────────────────────────────────────
-const enforceRateLimit = (key: string, limit: number): void => {
- if (!limiter.allow(key, limit, config.rateLimitWindowMs)) {
- throw new ApiError(429, "Rate limit exceeded");
- }
-};
+app.post("/v1/auth/challenge", async (c) => {
+ const body = challengeRequestSchema.parse(await c.req.json());
+ const store = getStore(c.env);
+ const challenge = await store.createChallenge();
-const restoreFromBackupIfAvailable = async (walletId: string): Promise => {
- const backup = store.getBackup(walletId);
- if (!backup) {
- return false;
+ const botEnabled = (c.env.TELEGRAM_BOT_TOKEN ?? "").length > 0;
+ const testAuthAllowed = (c.env.ALLOW_TEST_AUTH ?? "") === "true";
+ if (!botEnabled && testAuthAllowed && body.telegram_user_id) {
+ await store.resolveChallenge(challenge.id, body.telegram_user_id);
}
- await enclaveClient.importKey(walletId, backup.alg, backup.private_key);
- return true;
-};
-app.get("/health", (_req, res) => {
- res.json({ ok: true, service: "api" });
+ return c.json(
+ {
+ challenge_id: challenge.id,
+ expires_at: challenge.expires_at,
+ deep_link: botEnabled ? `https://t.me/${c.env.TELEGRAM_BOT_USERNAME}?start=${challenge.id}` : null,
+ },
+ 201,
+ );
});
-app.post("/v1/users", (req, res, next) => {
- try {
- const body = parse(createUserSchema, req.body);
- const { user, created } = store.createOrGetUser(body.telegram_user_id);
- if (created) {
- store.addAuditEvent({
- user_id: user.id,
- wallet_id: null,
- action: "user.create",
- metadata: { telegram_user_id: user.telegram_user_id }
- });
- }
- res.status(created ? 201 : 200).json(user);
- } catch (error) {
- next(error);
+app.post("/v1/auth/verify", async (c) => {
+ const body = verifySchema.parse(await c.req.json());
+ const store = getStore(c.env);
+
+ const challenge = await store.getChallenge(body.challenge_id);
+ if (!challenge) throw new HTTPException(404, { message: "Challenge not found or expired" });
+ if (!challenge.telegram_user_id) throw new HTTPException(202, { message: "Challenge not yet resolved" });
+
+ const { user, created } = await store.createOrGetUser(challenge.telegram_user_id);
+ if (created) {
+ await store.addAuditEvent({ user_id: user.id, identity_id: null, action: "user.create", metadata: { telegram_user_id: user.telegram_user_id } });
}
+
+ const ttl = parseInt(c.env.SESSION_TTL_SECONDS, 10);
+ const token = await signSessionToken({ sub: user.id, telegram_user_id: user.telegram_user_id }, c.env.SESSION_SIGNING_SECRET, ttl);
+ await store.addAuditEvent({ user_id: user.id, identity_id: null, action: "session.create", metadata: {} });
+
+ return c.json({ token, expires_in: ttl, user: { id: user.id, telegram_user_id: user.telegram_user_id, status: user.status } });
});
-app.post("/v1/sessions", (req, res, next) => {
- try {
- const body = parse(createSessionSchema, req.body);
- const user = store.getUserByTelegramId(body.telegram_user_id);
- if (!user) {
- throw new ApiError(404, "User not found, call POST /v1/users first");
- }
- if (config.requireOtp) {
- if (!body.otp || !config.validOtpCodes.includes(body.otp)) {
- throw new ApiError(401, "OTP validation failed");
- }
- }
- const token = signSessionToken({
- sub: user.id,
- telegram_user_id: user.telegram_user_id
- });
- store.addAuditEvent({
- user_id: user.id,
- wallet_id: null,
- action: "session.create",
- metadata: { otp_required: config.requireOtp }
- });
- res.json({
- token,
- expires_in: config.sessionTtlSeconds
- });
- } catch (error) {
- next(error);
- }
+// ── Telegram Webhook ──────────────────────────────────────────
+
+app.post("/telegram-webhook", async (c) => {
+ const update = await c.req.json();
+ const message = update.message;
+ if (!message?.text || !message.from) return c.json({ ok: true });
+
+ const text = message.text.trim();
+ if (!text.startsWith("/start ")) return c.json({ ok: true });
+
+ const challengeId = text.slice("/start ".length).trim();
+ if (!challengeId) return c.json({ ok: true });
+
+ const store = getStore(c.env);
+ const resolved = await store.resolveChallenge(challengeId, String(message.from.id));
+
+ const reply = resolved
+ ? "You're logged in! You can close this chat and go back to the app."
+ : "This login link has expired or was already used. Please request a new one.";
+ await sendTelegramMessage(c.env.TELEGRAM_BOT_TOKEN, message.chat.id, reply);
+
+ return c.json({ ok: true });
});
-app.post("/v1/wallets", requireAuth, async (req: Request, res, next) => {
- try {
- const authReq = req as AuthenticatedRequest;
- const user = currentUserFromRequest(authReq);
- enforceRateLimit(`user:${user.id}:wallet_create`, config.rateLimitPerUser);
- const body = parse(createWalletSchema, req.body);
- const walletId = uuidv4();
- const alg: SupportedAlg = body.alg ?? "secp256k1";
- const generated = await enclaveClient.generate(walletId, alg);
- const exported = await enclaveClient.exportKey(walletId);
- store.putBackup({
- wallet_id: walletId,
- alg: exported.alg,
- private_key: exported.private_key
- });
- const wallet = store.createWallet({
- id: walletId,
- user_id: user.id,
- alg,
- public_key: generated.public_key
- });
- store.addAuditEvent({
- user_id: user.id,
- wallet_id: wallet.id,
- action: "wallet.create",
- metadata: { alg: wallet.alg }
- });
- res.status(201).json(wallet);
- } catch (error) {
- next(error);
- }
+// ── Identities ────────────────────────────────────────────────
+
+app.get("/v1/identities", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const store = getStore(c.env);
+ const user = await currentUser(store, auth.sub);
+ const identities = await store.listIdentitiesForUser(user.id);
+ return c.json({ items: identities });
});
-app.post("/v1/wallets/:id/sign-intent", requireAuth, (req: Request, res, next) => {
- try {
- const authReq = req as AuthenticatedRequest;
- const user = currentUserFromRequest(authReq);
- const wallet = requireOwnedActiveWallet(req.params.id, user.id);
- enforceRateLimit(`user:${user.id}:sign_intent`, config.rateLimitPerUser);
- const body = parse(signIntentSchema, req.body);
- const digest = normalizeDigestHex(body.digest);
- const digestHash = InMemoryStore.digestHash(digest);
- const nonce = uuidv4();
- const ticketId = uuidv4();
- const ticket = signTicketToken({
- jti: ticketId,
- sub: user.id,
- wallet_id: wallet.id,
- digest_hash: digestHash,
- scope: body.scope ?? "sign",
- nonce
- });
- const expiresAt = new Date(Date.now() + config.ticketTtlSeconds * 1000).toISOString();
- store.createTicket({
- id: ticketId,
- wallet_id: wallet.id,
- digest_hash: digestHash,
- scope: "sign",
- expires_at: expiresAt,
- nonce
- });
- res.status(201).json({
- id: ticketId,
- wallet_id: wallet.id,
- digest_hash: digestHash,
- nonce,
- scope: "sign",
- expires_at: expiresAt,
- ticket
- });
- } catch (error) {
- next(error);
+app.post("/v1/identities", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const store = getStore(c.env);
+ const limiter = getLimiter(c.env);
+ const enclave = getEnclave(c.env);
+
+ const user = await currentUser(store, auth.sub);
+ await enforceRate(limiter, `user:${user.id}:identity_create`, c.env);
+
+ const body = createIdentitySchema.parse(await c.req.json());
+ const identityId = crypto.randomUUID();
+ const alg: SupportedAlg = body.alg ?? "secp256k1";
+
+ const generated = await enclave.generate(identityId, alg);
+ const exported = await enclave.exportKey(identityId);
+ await store.putBackup({ identity_id: identityId, alg: exported.alg, sealed_key: exported.sealed_key });
+
+ const identity = await store.createIdentity({ id: identityId, user_id: user.id, alg, public_key: generated.public_key });
+ await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.create", metadata: { alg: identity.alg } });
+
+ return c.json(identity, 201);
+});
+
+app.post("/v1/identities/:id/restore", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const identityId = c.req.param("id");
+ const store = getStore(c.env);
+ const enclave = getEnclave(c.env);
+
+ const user = await currentUser(store, auth.sub);
+
+ const existing = await store.getIdentity(identityId);
+ if (existing) {
+ if (existing.user_id !== user.id) throw new HTTPException(403, { message: "Identity does not belong to session user" });
+ await restoreBackup(store, enclave, identityId);
+ return c.json(existing);
}
+
+ const backup = await store.getBackup(identityId);
+ if (!backup) throw new HTTPException(404, { message: "No backup found for this identity" });
+
+ await enclave.importKey(identityId, backup.alg, stripWrappingQuotes(backup.sealed_key));
+
+ const body = (await c.req.json()) as { public_key?: string };
+ if (!body.public_key) throw new HTTPException(400, { message: "Missing public_key in request body" });
+
+ const identity = await store.createIdentity({ id: identityId, user_id: user.id, alg: backup.alg, public_key: body.public_key });
+ await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.restore", metadata: { alg: identity.alg } });
+
+ return c.json(identity);
});
-app.post("/v1/wallets/:id/sign", requireAuth, async (req: Request, res, next) => {
- try {
- const authReq = req as AuthenticatedRequest;
- const user = currentUserFromRequest(authReq);
- const wallet = requireOwnedActiveWallet(req.params.id, user.id);
- enforceRateLimit(`wallet:${wallet.id}:sign`, config.rateLimitPerWalletSign);
- const body = parse(signSchema, req.body);
- const digest = normalizeDigestHex(body.digest);
- const digestHash = InMemoryStore.digestHash(digest);
- const claims = verifyTicketToken(body.ticket);
- if (claims.sub !== user.id) {
- throw new ApiError(403, "Ticket user mismatch");
- }
- if (claims.wallet_id !== wallet.id) {
- throw new ApiError(403, "Ticket wallet mismatch");
- }
- if (claims.scope !== "sign") {
- throw new ApiError(403, "Ticket scope mismatch");
- }
- if (claims.digest_hash !== digestHash) {
- throw new ApiError(403, "Ticket digest mismatch");
- }
- const ticket = store.getTicket(claims.jti);
- if (!ticket) {
- throw new ApiError(404, "Ticket not found");
- }
- if (ticket.used_at) {
- throw new ApiError(409, "Ticket already used");
- }
- if (new Date(ticket.expires_at).getTime() <= Date.now()) {
- throw new ApiError(410, "Ticket expired");
- }
+app.post("/v1/identities/:id/sign-intent", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const identityId = c.req.param("id");
+ const store = getStore(c.env);
+ const limiter = getLimiter(c.env);
- let signature: string;
- try {
- const signed = await enclaveClient.sign(wallet.id, digest, body.ticket);
- signature = signed.signature;
- } catch (error) {
- if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) {
- throw error;
- }
- const restored = await restoreFromBackupIfAvailable(wallet.id);
- if (!restored) {
- throw new ApiError(409, "Key not present in enclave and no backup available");
- }
- const signed = await enclaveClient.sign(wallet.id, digest, body.ticket);
- signature = signed.signature;
- }
+ const user = await currentUser(store, auth.sub);
+ const identity = await ownedActiveIdentity(store, identityId, user.id);
+ await enforceRate(limiter, `user:${user.id}:sign_intent`, c.env);
- store.markTicketUsed(ticket.id);
- store.addAuditEvent({
- user_id: user.id,
- wallet_id: wallet.id,
- action: "wallet.sign",
- metadata: { digest_hash: digestHash }
- });
- res.json({ signature });
+ const body = signIntentSchema.parse(await c.req.json());
+ const digest = normalizeDigestHex(body.digest);
+ const digestHash = await CloudflareStore.digestHash(digest);
+ const nonce = crypto.randomUUID();
+ const ticketId = crypto.randomUUID();
+ const signatureType = body.signature_type;
+
+ const ticketTtl = parseInt(c.env.TICKET_TTL_SECONDS, 10);
+ const ticket = await signTicketToken(
+ { jti: ticketId, sub: user.id, identity_id: identity.id, digest_hash: digestHash, scope: "sign", nonce, signature_type: signatureType },
+ c.env.TICKET_SIGNING_SECRET,
+ ticketTtl,
+ );
+
+ const expiresAt = new Date(Date.now() + ticketTtl * 1000).toISOString();
+ await store.createTicket({ id: ticketId, identity_id: identity.id, digest_hash: digestHash, scope: "sign", expires_at: expiresAt, nonce }, ticketTtl);
+
+ return c.json({ id: ticketId, identity_id: identity.id, digest_hash: digestHash, nonce, scope: "sign", signature_type: signatureType, expires_at: expiresAt, ticket }, 201);
+});
+
+app.post("/v1/identities/:id/sign", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const identityId = c.req.param("id");
+ const store = getStore(c.env);
+ const limiter = getLimiter(c.env);
+ const enclave = getEnclave(c.env);
+
+ const user = await currentUser(store, auth.sub);
+ const identity = await ownedActiveIdentity(store, identityId, user.id);
+ await enforceRate(limiter, `identity:${identity.id}:sign`, c.env);
+
+ const body = signSchema.parse(await c.req.json());
+ const digest = normalizeDigestHex(body.digest);
+ const digestHash = await CloudflareStore.digestHash(digest);
+ const signatureType = body.signature_type;
+
+ const claims = await verifyTicketToken(body.ticket, c.env.TICKET_SIGNING_SECRET);
+ if (claims.sub !== user.id) throw new HTTPException(403, { message: "Ticket user mismatch" });
+ if (claims.identity_id !== identity.id) throw new HTTPException(403, { message: "Ticket identity mismatch" });
+ if (claims.scope !== "sign") throw new HTTPException(403, { message: "Ticket scope mismatch" });
+ if (claims.digest_hash !== digestHash) throw new HTTPException(403, { message: "Ticket digest mismatch" });
+
+ const ticket = await store.getTicket(claims.jti);
+ if (!ticket) throw new HTTPException(404, { message: "Ticket not found" });
+ if (ticket.used_at) throw new HTTPException(409, { message: "Ticket already used" });
+ if (new Date(ticket.expires_at).getTime() <= Date.now()) throw new HTTPException(410, { message: "Ticket expired" });
+
+ let signResult: { signature: string; r?: string; s?: string; v?: number };
+ try {
+ signResult = await enclave.sign(identity.id, digest, body.ticket, signatureType);
} catch (error) {
- next(error);
+ if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
+ if (!(await restoreBackup(store, enclave, identity.id))) {
+ throw new HTTPException(409, { message: "Key not present in enclave and no backup available" });
+ }
+ signResult = await enclave.sign(identity.id, digest, body.ticket, signatureType);
}
+
+ await store.markTicketUsed(ticket.id);
+ await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.sign", metadata: { digest_hash: digestHash, signature_type: signatureType } });
+
+ return c.json(signResult);
});
-app.delete("/v1/wallets/:id", requireAuth, async (req: Request, res, next) => {
- try {
- const authReq = req as AuthenticatedRequest;
- const user = currentUserFromRequest(authReq);
- const wallet = requireOwnedActiveWallet(req.params.id, user.id);
- enforceRateLimit(`wallet:${wallet.id}:destroy`, config.rateLimitPerUser);
+app.post("/v1/identities/:id/sign-batch", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const identityId = c.req.param("id");
+ const store = getStore(c.env);
+ const limiter = getLimiter(c.env);
+ const enclave = getEnclave(c.env);
+ const user = await currentUser(store, auth.sub);
+ const identity = await ownedActiveIdentity(store, identityId, user.id);
+ await enforceRate(limiter, `identity:${identity.id}:sign`, c.env);
+
+ const body = signBatchSchema.parse(await c.req.json());
+ const ticketTtl = parseInt(c.env.TICKET_TTL_SECONDS, 10);
+ const signatures: Array<{ signature: string; r?: string; s?: string; v?: number }> = [];
+
+ for (const item of body.digests) {
+ const digest = normalizeDigestHex(item.digest);
+ const digestHash = await CloudflareStore.digestHash(digest);
+ const nonce = crypto.randomUUID();
+ const ticketId = crypto.randomUUID();
+ const signatureType = item.signature_type;
+
+ const ticket = await signTicketToken(
+ { jti: ticketId, sub: user.id, identity_id: identity.id, digest_hash: digestHash, scope: "sign", nonce, signature_type: signatureType },
+ c.env.TICKET_SIGNING_SECRET,
+ ticketTtl,
+ );
+
+ const expiresAt = new Date(Date.now() + ticketTtl * 1000).toISOString();
+ await store.createTicket({ id: ticketId, identity_id: identity.id, digest_hash: digestHash, scope: "sign", expires_at: expiresAt, nonce }, ticketTtl);
+
+ let signResult: { signature: string; r?: string; s?: string; v?: number };
try {
- await enclaveClient.destroy(wallet.id);
+ signResult = await enclave.sign(identity.id, digest, ticket, signatureType);
} catch (error) {
- if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) {
- throw error;
- }
- const restored = await restoreFromBackupIfAvailable(wallet.id);
- if (restored) {
- await enclaveClient.destroy(wallet.id);
+ if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
+ if (!(await restoreBackup(store, enclave, identity.id))) {
+ throw new HTTPException(409, { message: "Key not present in enclave and no backup available" });
}
+ signResult = await enclave.sign(identity.id, digest, ticket, signatureType);
}
- store.markWalletDestroyed(wallet.id);
- store.deleteBackup(wallet.id);
- store.addAuditEvent({
- user_id: user.id,
- wallet_id: wallet.id,
- action: "wallet.destroy",
- metadata: { reason: "user-request" }
- });
- res.json({ ok: true });
- } catch (error) {
- next(error);
+
+ await store.markTicketUsed(ticketId);
+ signatures.push(signResult);
}
+
+ await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.sign", metadata: { batch_size: body.digests.length } });
+
+ return c.json({ signatures });
});
-app.get("/v1/audit", requireAuth, (req: Request, res, next) => {
+app.delete("/v1/identities/:id", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const identityId = c.req.param("id");
+ const store = getStore(c.env);
+ const limiter = getLimiter(c.env);
+ const enclave = getEnclave(c.env);
+
+ const user = await currentUser(store, auth.sub);
+ const identity = await ownedActiveIdentity(store, identityId, user.id);
+ await enforceRate(limiter, `identity:${identity.id}:destroy`, c.env);
+
try {
- const authReq = req as AuthenticatedRequest;
- const user = currentUserFromRequest(authReq);
- const { limit, offset } = parsePagination(req);
- const items = store.listAuditEventsForUser(user.id, limit, offset);
- res.json({
- items,
- limit,
- offset,
- count: items.length
- });
+ await enclave.destroy(identity.id);
} catch (error) {
- next(error);
+ if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
+ if (await restoreBackup(store, enclave, identity.id)) {
+ await enclave.destroy(identity.id);
+ }
}
+
+ await store.markIdentityDestroyed(identity.id);
+ await store.deleteBackup(identity.id);
+ await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.destroy", metadata: { reason: "user-request" } });
+
+ return c.json({ ok: true });
});
-app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
- if (error instanceof z.ZodError) {
- res.status(400).json({ error: "Validation error", details: error.flatten() });
- return;
- }
- if (error instanceof ApiError) {
- res.status(error.status).json({ error: error.message });
- return;
+// ── Audit ─────────────────────────────────────────────────────
+
+app.get("/v1/audit", requireAuth, async (c) => {
+ const auth = c.get("auth");
+ const store = getStore(c.env);
+
+ const user = await currentUser(store, auth.sub);
+ const { limit, offset } = paginationSchema.parse({
+ limit: c.req.query("limit"),
+ offset: c.req.query("offset"),
+ });
+ const items = await store.listAuditEventsForUser(user.id, limit, offset);
+
+ return c.json({ items, limit, offset, count: items.length });
+});
+
+// ── Error handler ─────────────────────────────────────────────
+
+app.onError((err, c) => {
+ if (err instanceof z.ZodError) {
+ return c.json({ error: "Validation error", details: err.flatten() }, 400);
}
- if (error instanceof jwt.JsonWebTokenError) {
- res.status(401).json({ error: "Invalid or expired ticket token" });
- return;
+ if (err instanceof HTTPException) {
+ return c.json({ error: err.message }, err.status as any);
}
- if (error instanceof EnclaveClientError) {
- res.status(502).json({ error: `Enclave error: ${error.message}` });
- return;
+ if (err instanceof EnclaveClientError) {
+ return c.json({ error: `Enclave error: ${err.message}` }, 502);
}
- const message = error instanceof Error ? error.message : "Internal server error";
- res.status(500).json({ error: message });
+ console.error("Unhandled error:", err);
+ return c.json({ error: "Internal server error" }, 500);
});
-app.listen(config.port, () => {
- // eslint-disable-next-line no-console
- console.log(`API service listening on :${config.port}`);
-});
-
-class ApiError extends Error {
- constructor(
- public readonly status: number,
- message: string
- ) {
- super(message);
- }
-}
+export default app;
diff --git a/api/src/rateLimit.ts b/api/src/rateLimit.ts
index d95ac4d..e8383e4 100644
--- a/api/src/rateLimit.ts
+++ b/api/src/rateLimit.ts
@@ -1,15 +1,16 @@
-export class SlidingWindowRateLimiter {
- private readonly entries = new Map();
+export class KVRateLimiter {
+ constructor(private readonly kv: KVNamespace) {}
- allow(key: string, limit: number, windowMs: number): boolean {
- const now = Date.now();
- const kept = (this.entries.get(key) ?? []).filter((timestamp) => now - timestamp < windowMs);
- if (kept.length >= limit) {
- this.entries.set(key, kept);
- return false;
- }
- kept.push(now);
- this.entries.set(key, kept);
+ async allow(key: string, limit: number, windowMs: number): Promise {
+ const windowKey = `rl:${key}:${Math.floor(Date.now() / windowMs)}`;
+ const current = await this.kv.get(windowKey);
+ const count = current ? parseInt(current, 10) : 0;
+
+ if (count >= limit) return false;
+
+ await this.kv.put(windowKey, String(count + 1), {
+ expirationTtl: Math.ceil(windowMs / 1000) + 60,
+ });
return true;
}
}
diff --git a/api/src/store.ts b/api/src/store.ts
index 5ddaebc..2645775 100644
--- a/api/src/store.ts
+++ b/api/src/store.ts
@@ -1,153 +1,199 @@
-import { createHash } from "node:crypto";
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname } from "node:path";
-import { v4 as uuidv4 } from "uuid";
-import type { AuditEvent, KeyBackup, Ticket, User, Wallet } from "./types.js";
-
-export class InMemoryStore {
- private readonly usersById = new Map();
- private readonly usersByTelegramId = new Map();
- private readonly walletsById = new Map();
- private readonly ticketsById = new Map();
- private readonly auditEvents: AuditEvent[] = [];
- private readonly backupsByWalletId = new Map();
- private readonly backupFilePath: string;
-
- constructor(backupFilePath: string) {
- this.backupFilePath = backupFilePath;
- this.loadBackupsFromDisk();
- }
-
- createOrGetUser(telegramUserId: string): { user: User; created: boolean } {
- const existing = this.usersByTelegramId.get(telegramUserId);
- if (existing) {
- return { user: existing, created: false };
- }
+import type { AuditEvent, Challenge, Identity, KeyBackup, Ticket, User } from "./types.js";
+
+export class CloudflareStore {
+ constructor(
+ private readonly db: D1Database,
+ private readonly kvTickets: KVNamespace,
+ private readonly challengeTtlSeconds: number,
+ ) {}
+
+ // ── Users ──────────────────────────────────────────────────
+
+ async createOrGetUser(telegramUserId: string): Promise<{ user: User; created: boolean }> {
+ const existing = await this.db
+ .prepare("SELECT * FROM users WHERE telegram_user_id = ?")
+ .bind(telegramUserId)
+ .first();
+ if (existing) return { user: existing, created: false };
+
const user: User = {
- id: uuidv4(),
+ id: crypto.randomUUID(),
telegram_user_id: telegramUserId,
- created_at: new Date().toISOString()
+ status: "active",
+ created_at: new Date().toISOString(),
};
- this.usersById.set(user.id, user);
- this.usersByTelegramId.set(user.telegram_user_id, user);
+ await this.db
+ .prepare("INSERT INTO users (id, telegram_user_id, status, created_at) VALUES (?, ?, ?, ?)")
+ .bind(user.id, user.telegram_user_id, user.status, user.created_at)
+ .run();
return { user, created: true };
}
- getUserByTelegramId(telegramUserId: string): User | undefined {
- return this.usersByTelegramId.get(telegramUserId);
+ async getUserById(userId: string): Promise {
+ const row = await this.db.prepare("SELECT * FROM users WHERE id = ?").bind(userId).first();
+ return row ?? undefined;
}
- getUserById(userId: string): User | undefined {
- return this.usersById.get(userId);
- }
+ // ── Identities ─────────────────────────────────────────────
- createWallet(input: Omit): Wallet {
- const wallet: Wallet = {
+ async createIdentity(input: Omit): Promise {
+ const identity: Identity = {
...input,
status: "active",
- created_at: new Date().toISOString()
+ created_at: new Date().toISOString(),
};
- this.walletsById.set(wallet.id, wallet);
- return wallet;
+ await this.db
+ .prepare("INSERT INTO identities (id, user_id, alg, public_key, status, created_at) VALUES (?, ?, ?, ?, ?, ?)")
+ .bind(identity.id, identity.user_id, identity.alg, identity.public_key, identity.status, identity.created_at)
+ .run();
+ return identity;
}
- getWallet(walletId: string): Wallet | undefined {
- return this.walletsById.get(walletId);
+ async getIdentity(identityId: string): Promise {
+ const row = await this.db.prepare("SELECT * FROM identities WHERE id = ?").bind(identityId).first();
+ return row ?? undefined;
}
- listWalletsByUser(userId: string): Wallet[] {
- return [...this.walletsById.values()].filter((wallet) => wallet.user_id === userId);
+ async markIdentityDestroyed(identityId: string): Promise {
+ await this.db.prepare("UPDATE identities SET status = 'destroyed' WHERE id = ?").bind(identityId).run();
}
- markWalletDestroyed(walletId: string): void {
- const wallet = this.walletsById.get(walletId);
- if (!wallet) {
- return;
- }
- wallet.status = "destroyed";
- this.walletsById.set(walletId, wallet);
+ async listIdentitiesForUser(userId: string): Promise {
+ const result = await this.db
+ .prepare("SELECT * FROM identities WHERE user_id = ? AND status = 'active' ORDER BY created_at DESC")
+ .bind(userId)
+ .all();
+ return result.results ?? [];
}
- createTicket(input: Omit): Ticket {
+ // ── Tickets (KV with TTL) ──────────────────────────────────
+
+ async createTicket(input: Omit, ttlSeconds: number): Promise {
const ticket: Ticket = { ...input, used_at: null };
- this.ticketsById.set(ticket.id, ticket);
+ await this.kvTickets.put(ticket.id, JSON.stringify(ticket), { expirationTtl: ttlSeconds });
return ticket;
}
- getTicket(ticketId: string): Ticket | undefined {
- return this.ticketsById.get(ticketId);
+ async getTicket(ticketId: string): Promise {
+ const raw = await this.kvTickets.get(ticketId);
+ if (!raw) return undefined;
+ return JSON.parse(raw) as Ticket;
}
- markTicketUsed(ticketId: string): void {
- const ticket = this.ticketsById.get(ticketId);
- if (!ticket) {
- return;
- }
+ async markTicketUsed(ticketId: string): Promise {
+ const ticket = await this.getTicket(ticketId);
+ if (!ticket) return;
ticket.used_at = new Date().toISOString();
- this.ticketsById.set(ticketId, ticket);
+ // Keep in KV so replays are rejected until natural expiry
+ await this.kvTickets.put(ticketId, JSON.stringify(ticket));
+ }
+
+ // ── Challenges (D1 for strong consistency) ──────────────────
+
+ async createChallenge(): Promise {
+ const now = new Date();
+ const challenge: Challenge = {
+ id: crypto.randomUUID(),
+ telegram_user_id: null,
+ created_at: now.toISOString(),
+ expires_at: new Date(now.getTime() + this.challengeTtlSeconds * 1000).toISOString(),
+ };
+ await this.db
+ .prepare("INSERT INTO challenges (id, telegram_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
+ .bind(challenge.id, null, challenge.created_at, challenge.expires_at)
+ .run();
+ return challenge;
}
- addAuditEvent(event: Omit): AuditEvent {
+ async getChallenge(challengeId: string): Promise {
+ const row = await this.db
+ .prepare("SELECT * FROM challenges WHERE id = ? AND expires_at > ?")
+ .bind(challengeId, new Date().toISOString())
+ .first();
+ return row ?? undefined;
+ }
+
+ async resolveChallenge(challengeId: string, telegramUserId: string): Promise {
+ const result = await this.db
+ .prepare("UPDATE challenges SET telegram_user_id = ? WHERE id = ? AND telegram_user_id IS NULL AND expires_at > ?")
+ .bind(telegramUserId, challengeId, new Date().toISOString())
+ .run();
+ return (result.meta?.changes ?? 0) > 0;
+ }
+
+ // ── Audit Events ───────────────────────────────────────────
+
+ async addAuditEvent(event: Omit): Promise {
const auditEvent: AuditEvent = {
...event,
- id: uuidv4(),
- created_at: new Date().toISOString()
+ id: crypto.randomUUID(),
+ created_at: new Date().toISOString(),
};
- this.auditEvents.push(auditEvent);
+ await this.db
+ .prepare("INSERT INTO audit_events (id, user_id, identity_id, action, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)")
+ .bind(auditEvent.id, auditEvent.user_id, auditEvent.identity_id, auditEvent.action, JSON.stringify(auditEvent.metadata), auditEvent.created_at)
+ .run();
return auditEvent;
}
- listAuditEventsForUser(userId: string, limit: number, offset: number): AuditEvent[] {
- return this.auditEvents
- .filter((event) => event.user_id === userId)
- .slice(offset, offset + limit);
+ async listAuditEventsForUser(userId: string, limit: number, offset: number): Promise {
+ const result = await this.db
+ .prepare("SELECT * FROM audit_events WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?")
+ .bind(userId, limit, offset)
+ .all();
+ return (result.results ?? []).map((row) => ({
+ ...row,
+ metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
+ }));
}
- putBackup(backup: Omit): KeyBackup {
+ // ── Key Backups ────────────────────────────────────────────
+
+ async putBackup(backup: Omit): Promise {
const now = new Date().toISOString();
- const existing = this.backupsByWalletId.get(backup.wallet_id);
+ const existing = await this.getBackup(backup.identity_id);
const next: KeyBackup = {
...backup,
created_at: existing?.created_at ?? now,
- updated_at: now
+ updated_at: now,
};
- this.backupsByWalletId.set(next.wallet_id, next);
- this.persistBackupsToDisk();
+ await this.db
+ .prepare(
+ "INSERT INTO key_backups (identity_id, alg, sealed_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(identity_id) DO UPDATE SET alg = excluded.alg, sealed_key = excluded.sealed_key, updated_at = excluded.updated_at",
+ )
+ .bind(next.identity_id, next.alg, next.sealed_key, next.created_at, next.updated_at)
+ .run();
return next;
}
- getBackup(walletId: string): KeyBackup | undefined {
- return this.backupsByWalletId.get(walletId);
+ async getBackup(identityId: string): Promise {
+ const row = await this.db.prepare("SELECT * FROM key_backups WHERE identity_id = ?").bind(identityId).first();
+ return row ?? undefined;
}
- deleteBackup(walletId: string): void {
- this.backupsByWalletId.delete(walletId);
- this.persistBackupsToDisk();
+ async deleteBackup(identityId: string): Promise {
+ await this.db.prepare("DELETE FROM key_backups WHERE identity_id = ?").bind(identityId).run();
}
- static digestHash(digestHex: string): string {
- return createHash("sha256").update(Buffer.from(digestHex, "hex")).digest("hex");
- }
+ // ── Utilities ──────────────────────────────────────────────
- private loadBackupsFromDisk(): void {
- if (!existsSync(this.backupFilePath)) {
- return;
- }
- try {
- const raw = readFileSync(this.backupFilePath, "utf-8");
- const parsed = JSON.parse(raw) as Record;
- for (const [walletId, backup] of Object.entries(parsed)) {
- this.backupsByWalletId.set(walletId, backup);
- }
- } catch {
- // Ignore malformed backup files in MVP mode.
- }
+ static async digestHash(digestHex: string): Promise {
+ const bytes = hexToBytes(digestHex);
+ const hash = await crypto.subtle.digest("SHA-256", bytes.buffer as ArrayBuffer);
+ return bytesToHex(new Uint8Array(hash));
}
+}
- private persistBackupsToDisk(): void {
- const parent = dirname(this.backupFilePath);
- mkdirSync(parent, { recursive: true });
- const serializable = Object.fromEntries(this.backupsByWalletId.entries());
- writeFileSync(this.backupFilePath, JSON.stringify(serializable, null, 2), "utf-8");
+function hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
+ return bytes;
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
}
diff --git a/api/src/types.ts b/api/src/types.ts
index d7cc5b0..3a66ac8 100644
--- a/api/src/types.ts
+++ b/api/src/types.ts
@@ -3,10 +3,11 @@ export type SupportedAlg = "secp256k1";
export interface User {
id: string;
telegram_user_id: string;
+ status: "pending" | "active";
created_at: string;
}
-export interface Wallet {
+export interface Identity {
id: string;
user_id: string;
alg: SupportedAlg;
@@ -17,7 +18,7 @@ export interface Wallet {
export interface Ticket {
id: string;
- wallet_id: string;
+ identity_id: string;
digest_hash: string;
scope: "sign";
expires_at: string;
@@ -28,20 +29,27 @@ export interface Ticket {
export interface AuditEvent {
id: string;
user_id: string;
- wallet_id: string | null;
- action: "user.create" | "session.create" | "wallet.create" | "wallet.sign" | "wallet.destroy";
+ identity_id: string | null;
+ action: "user.create" | "session.create" | "identity.create" | "identity.restore" | "identity.sign" | "identity.destroy";
metadata: Record;
created_at: string;
}
export interface KeyBackup {
- wallet_id: string;
+ identity_id: string;
alg: SupportedAlg;
- private_key: string;
+ sealed_key: string;
created_at: string;
updated_at: string;
}
+export interface Challenge {
+ id: string;
+ telegram_user_id: string | null;
+ created_at: string;
+ expires_at: string;
+}
+
export interface SessionClaims {
sub: string;
telegram_user_id: string;
@@ -50,8 +58,9 @@ export interface SessionClaims {
export interface TicketClaims {
jti: string;
sub: string;
- wallet_id: string;
+ identity_id: string;
digest_hash: string;
scope: "sign";
nonce: string;
+ signature_type?: "schnorr" | "ecdsa";
}
diff --git a/api/src/validation.ts b/api/src/validation.ts
index 214ee22..3a03cba 100644
--- a/api/src/validation.ts
+++ b/api/src/validation.ts
@@ -1,37 +1,50 @@
import { z } from "zod";
-export const telegramUserIdSchema = z.string().min(1).max(64);
-
const digestPattern = /^([a-fA-F0-9]{64}|0x[a-fA-F0-9]{64})$/;
export const normalizeDigestHex = (digest: string): string => {
return digest.startsWith("0x") ? digest.slice(2).toLowerCase() : digest.toLowerCase();
};
-export const createUserSchema = z.object({
- telegram_user_id: telegramUserIdSchema
+export const challengeRequestSchema = z.object({
+ telegram_user_id: z.string().min(1).max(64).optional()
});
-export const createSessionSchema = z.object({
- telegram_user_id: telegramUserIdSchema,
- otp: z.string().min(1).max(32).optional()
+export const verifySchema = z.object({
+ challenge_id: z.string().uuid()
});
-export const createWalletSchema = z.object({
+export const createIdentitySchema = z.object({
alg: z.literal("secp256k1").optional()
});
export const signIntentSchema = z.object({
digest: z.string().regex(digestPattern),
- scope: z.literal("sign").optional()
+ scope: z.literal("sign").optional(),
+ signature_type: z.enum(["schnorr", "ecdsa"]).default("schnorr")
});
export const signSchema = z.object({
digest: z.string().regex(digestPattern),
- ticket: z.string().min(32).max(4096)
+ ticket: z.string().min(32).max(4096),
+ signature_type: z.enum(["schnorr", "ecdsa"]).default("schnorr")
+});
+
+export const signBatchSchema = z.object({
+ digests: z.array(
+ z.object({
+ digest: z.string().regex(digestPattern),
+ scope: z.literal("sign").optional(),
+ signature_type: z.enum(["schnorr", "ecdsa"]).default("schnorr")
+ })
+ ).min(1).max(100)
+});
+
+export const botSessionSchema = z.object({
+ telegram_user_id: z.string().min(1).max(64)
});
export const paginationSchema = z.object({
limit: z.coerce.number().min(1).max(200).default(50),
offset: z.coerce.number().min(0).default(0)
-});
+});
\ No newline at end of file
diff --git a/api/tsconfig.json b/api/tsconfig.json
index 8cade7f..ddb09d6 100644
--- a/api/tsconfig.json
+++ b/api/tsconfig.json
@@ -1,14 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": [
- "node"
+ "@cloudflare/workers-types"
],
"outDir": "dist"
},
diff --git a/api/wrangler.toml b/api/wrangler.toml
new file mode 100644
index 0000000..19d7d52
--- /dev/null
+++ b/api/wrangler.toml
@@ -0,0 +1,75 @@
+name = "clw-cash-api"
+main = "src/index.ts"
+compatibility_date = "2026-02-01"
+compatibility_flags = ["nodejs_compat"]
+
+# D1 Database
+[[d1_databases]]
+binding = "DB"
+database_name = "clw-cash-db"
+database_id = "e09e3b97-6ae9-4778-b029-e7f2f5bf24f8" # Fill after: wrangler d1 create clw-cash-db
+migrations_dir = "migrations"
+
+# KV Namespaces — fill IDs after creating with:
+# npx wrangler kv namespace create KV_TICKETS
+# npx wrangler kv namespace create KV_RATE_LIMIT
+[[kv_namespaces]]
+binding = "KV_TICKETS"
+id = "419b8e6d63084afaaa245d9b3fb6faf7"
+
+[[kv_namespaces]]
+binding = "KV_RATE_LIMIT"
+id = "969b388a46454ae1886d8c37b3e54ee2"
+
+# Environment variables (non-secret)
+[vars]
+ENCLAVE_BASE_URL = "http://127.0.0.1:7000"
+TICKET_TTL_SECONDS = "90"
+SESSION_TTL_SECONDS = "3600"
+CHALLENGE_TTL_SECONDS = "300"
+RATE_LIMIT_WINDOW_MS = "60000"
+RATE_LIMIT_PER_USER = "60"
+RATE_LIMIT_PER_IDENTITY_SIGN = "20"
+ALLOWED_ORIGINS = "http://localhost:5173"
+ALLOW_TEST_AUTH = "true"
+
+# Secrets (set via: wrangler secret put )
+# INTERNAL_API_KEY
+# TICKET_SIGNING_SECRET
+# SESSION_SIGNING_SECRET
+# TELEGRAM_BOT_TOKEN
+# TELEGRAM_BOT_USERNAME
+# EV_API_KEY
+
+# ── Production ──────────────────────────────────────────────
+[env.production]
+name = "clw-cash-api"
+
+[env.production.vars]
+ENCLAVE_BASE_URL = "https://clw-cash-signer.app-366535fdf2b7.enclave.evervault.com"
+TICKET_TTL_SECONDS = "90"
+SESSION_TTL_SECONDS = "3600"
+CHALLENGE_TTL_SECONDS = "300"
+RATE_LIMIT_WINDOW_MS = "60000"
+RATE_LIMIT_PER_USER = "60"
+RATE_LIMIT_PER_IDENTITY_SIGN = "20"
+ALLOWED_ORIGINS = "https://pay.clw.cash"
+
+[[env.production.d1_databases]]
+binding = "DB"
+database_name = "clw-cash-db"
+database_id = "e09e3b97-6ae9-4778-b029-e7f2f5bf24f8"
+migrations_dir = "migrations"
+
+[[env.production.kv_namespaces]]
+binding = "KV_TICKETS"
+id = "419b8e6d63084afaaa245d9b3fb6faf7"
+
+[[env.production.kv_namespaces]]
+binding = "KV_RATE_LIMIT"
+id = "969b388a46454ae1886d8c37b3e54ee2"
+
+# Custom domain — after first deploy, run:
+# npx wrangler deploy --env production
+# Then in Cloudflare Dashboard > Workers & Pages > clw-cash-api > Settings > Domains & Routes
+# Add custom domain: api.clw.cash (Cloudflare auto-creates the DNS record)
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000..46db91e
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,59 @@
+# clw-cash
+
+Bitcoin & Stablecoin agent wallet CLI. Keys held in a secure enclave.
+
+## Install
+
+```bash
+npm install -g clw-cash
+```
+
+## Quick Start
+
+```bash
+# Initialize with API and Ark server
+cash init --api-url --token --ark-server
+
+# Login via Telegram (refresh token)
+cash login
+
+# Check balance
+cash balance
+
+# Send funds
+cash send --amount 100000 --currency btc --where arkade --to ark1q...
+cash send lnbc500n1...
+
+# Receive funds
+cash receive --amount 100000 --currency btc --where lightning
+```
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `cash init` | Configure API endpoint, token, and Ark server |
+| `cash login` | Re-authenticate via Telegram (refresh token) |
+| `cash balance` | Show wallet balance |
+| `cash send` | Send Bitcoin or stablecoins |
+| `cash receive` | Generate receive address/invoice |
+| `cash start` | Start background daemon (swap monitoring) |
+| `cash stop` | Stop background daemon |
+| `cash status` | Show daemon status |
+| `cash swap ` | Check swap status (local + LendaSat API) |
+| `cash swaps` | List swaps (last 5 per category) |
+| `cash claim ` | Manually claim a swap (reveal preimage) |
+| `cash refund ` | Manually refund a swap |
+
+### Supported currencies and networks
+
+- **Currency:** `btc`, `usdt`, `usdc`
+- **Where:** `onchain`, `lightning`, `arkade`, `polygon`, `arbitrum`, `ethereum`
+
+## How It Works
+
+Keys are generated and stored inside an [Evervault Enclave](https://evervault.com/primitives/enclaves) -- a trusted execution environment. The CLI never has access to private keys directly. All signing is delegated to the enclave via authenticated ticket-based requests.
+
+## License
+
+MIT
diff --git a/cli/SKILL.md b/cli/SKILL.md
new file mode 100644
index 0000000..d2797f7
--- /dev/null
+++ b/cli/SKILL.md
@@ -0,0 +1,356 @@
+# cash - Bitcoin & Stablecoin Agent Wallet
+
+A command-line tool for sending and receiving Bitcoin and stablecoins. Keys are held in a secure enclave — the CLI never touches private keys.
+
+Success output is JSON to stdout. Error output is JSON to stderr. Exit code 0 = success, 1 = error.
+
+## Agent Rules
+
+- **Run commands directly.** No narration before or after. Just run the command and return the result.
+- **Don't retry on failure.** If a command fails, report the error — don't retry without asking.
+- **Ask before sending.** Always confirm with the user before running `cash send`. Balance checks and receives are safe.
+- **One command at a time.** Don't chain or batch. Run, read output, decide next step.
+- **Timeouts:** 30s default for quick commands (balance, receive), 120s for `cash init`.
+- **Default to Arkade for BTC receives.** When the user asks for a Bitcoin address, use `--where arkade`. Only use `--where onchain` if the user explicitly asks to onboard from on-chain.
+
+### Reactive Polling
+
+For long-running operations (swaps, pending receives), keep the user informed:
+
+- **Run in background with short yield** — don't block the conversation for >15s without an update. Use `yieldMs=5000` or `background=true` for swap commands.
+- **Stream progress** — relay status lines as they appear:
+ - "Executing swap BTC → USDC..."
+ - "Waiting for on-chain confirmation..."
+ - "Swap claimed! New balance: X sats"
+- **Poll with `process action=log`** — if a command is backgrounded, check its output periodically and report the latest status line to the user.
+- **Timeout strategy:**
+ - Quick commands (balance, receive, config): 30s
+ - Swaps/settlements: start with `yieldMs=10000`, then poll process log
+ - If still running after 60s, tell user "still processing, I'll let you know when done"
+- **Never go silent** for >15s during a long operation. Always provide a status update.
+- **Never let a timeout kill a command silently** — if a command times out, tell the user what happened and suggest next steps (e.g., `cash swap ` to check status).
+
+## Setup
+
+```bash
+# Install globally
+npm install -g clw-cash
+# First time — authenticates, creates identity, saves config, starts daemon
+cash init
+# Re-authenticate when session expires (blocking — waits up to 120s)
+cash login
+```
+
+`init` handles authentication automatically (Telegram 2FA in production, auto-resolves in test mode). If an identity already exists on the server (e.g., from a previous install or sandbox reset), it **auto-recovers** it instead of creating a new one. Config is saved to `~/.clw-cash/config.json`, and it **auto-starts a background daemon** for monitoring swaps (Lightning HTLC claiming and LendaSwap polling).
+
+If the session token expires, run `cash login` to re-authenticate. The daemon can be stopped with `cash stop` and restarted with `cash start`.
+
+### Non-blocking Auth (for Telegram bots)
+
+When running as a Telegram bot, use `--start` to avoid blocking. The daemon polls in the background and sends a Telegram reply when auth completes:
+
+```bash
+# Returns immediately with a deep link. Daemon handles the rest.
+cash login --start --bot-token --chat-id --message-id
+# -> {"ok": true, "data": {"deepLink": "https://t.me/clw_cash_bot?start=...", "challengeId": "..."}}
+```
+
+The daemon will reply to `` with "Connected, welcome back!" once the user authenticates via the deep link. Requires the daemon to be running (`cash start`).
+
+You can also pass flags explicitly: `cash init --api-url --token --ark-server --network `.
+
+Or set environment variables:
+
+```bash
+CLW_API_URL=https://api.clw.cash
+CLW_SESSION_TOKEN=
+CLW_IDENTITY_ID=
+CLW_PUBLIC_KEY=
+CLW_ARK_SERVER_URL=https://ark.clw.cash
+CLW_NETWORK=bitcoin # bitcoin or testnet
+CLW_DAEMON_PORT=3457 # default: 3457
+```
+
+## Commands
+
+### Send Bitcoin
+
+```bash
+# Send sats via Ark (instant, off-chain)
+cash send --amount 100000 --currency sats --where arkade --to
+
+# Send sats on-chain
+cash send --amount 100000 --currency sats --where onchain --to
+
+# Pay a Lightning invoice
+cash send --amount 50000 --currency sats --where lightning --to
+
+# Auto-detect invoice format (bolt11 or BIP21, positional arg)
+cash send lnbc500n1pj...
+cash send bitcoin:bc1q...?amount=0.001&lightning=lnbc...
+```
+
+### Send Stablecoins (BTC to Stablecoin swap)
+
+```bash
+# Swap BTC to USDT on Polygon
+cash send --amount 10 --currency usdt --where polygon --to <0x-address>
+
+# Swap BTC to USDC on Arbitrum
+cash send --amount 50 --currency usdc --where arbitrum --to <0x-address>
+```
+
+### Receive Bitcoin
+
+```bash
+# Get an Ark address
+cash receive --amount 100000 --currency sats --where arkade
+# -> {"ok": true, "data": {"address": "ark1q...", "type": "ark", "amount": 100000}}
+
+# Create a Lightning invoice
+cash receive --amount 50000 --currency sats --where lightning
+# -> {"ok": true, "data": {"bolt11": "lnbc...", "paymentHash": "...", "amount": 50000}}
+
+# Get a boarding (on-chain) address
+cash receive --amount 100000 --currency sats --where onchain
+# -> {"ok": true, "data": {"address": "bc1q...", "type": "onchain", "amount": 100000}}
+```
+
+### Receive Stablecoins (Stablecoin to BTC swap)
+
+```bash
+# Receive stablecoins — sender picks chain on web page (no --where)
+cash receive --amount 10 --currency usdt
+# -> {"ok": true, "data": {"paymentUrl": "https://pay.clw.cash?amount=10&to=ark1q...¤cy=usdt", "amount": 10, "currency": "usdt", "targetAddress": "ark1q..."}}
+
+# Receive stablecoins — specify chain (creates swap, generates payment URL with swap ID)
+cash receive --amount 10 --currency usdt --where polygon
+# -> {"ok": true, "data": {"paymentUrl": "https://pay.clw.cash?id=", "swapId": "...", "amount": 10, "token": "usdt0_pol", "chain": "polygon", "targetAddress": "ark1q..."}}
+```
+
+### Check Balance
+
+```bash
+cash balance
+# -> {"ok": true, "data": {"total": 250000, "offchain": {"settled": 50000, "preconfirmed": 20000, "available": 70000}, "onchain": {"confirmed": 30000, "total": 30000}}}
+```
+
+### Configuration
+
+```bash
+cash config
+# -> {"ok": true, "data": {"apiBaseUrl": {"value": "...", "source": "file"}, "payBaseUrl": "...", "arkServerUrl": {"value": "...", "source": "..."}, "network": {"value": "bitcoin", "source": "default"}, ..., "daemonPort": 3457, "configFile": "~/.clw-cash/config.json", "dataDir": "~/.clw-cash/data", "session": "active", "sessionExpiresAt": 1739...}}
+```
+
+### Swap Management
+
+```bash
+# Check a single swap by ID
+cash swap
+# -> {"ok": true, "data": {"id": "...", "status": "funded", "direction": "btc_to_stablecoin", "local": {"direction": "...", "status": "...", "sourceToken": "...", "targetToken": "...", "sourceAmount": ..., "targetAmount": ..., "exchangeRate": ..., "createdAt": "...", "completedAt": null, "txid": null}, "remote": {...}}}
+
+# List swaps (grouped by status, last 5 per category)
+cash swaps
+# -> {"ok": true, "data": {"lendaswap": {"pending": [...], "claimed": [...], "refunded": [...], "expired": [...], "failed": [...]}}}
+
+# Filter by status
+cash swaps --pending
+cash swaps --claimed --limit 10
+
+# Manually claim a completed swap
+cash claim
+# -> {"ok": true, "data": {"success": true, "txHash": "0x...", "chain": "polygon"}}
+
+# Refund a BTC→Stablecoin swap (refunds directly via SDK)
+cash refund
+# -> {"ok": true, "data": {"success": true, "txId": "...", "refundAmount": 95000}}
+
+# Refund a Stablecoin→BTC swap (returns unsigned EVM tx call data)
+cash refund
+# -> {"ok": true, "data": {"type": "evm_refund", "swapId": "...", "timelockExpired": true, "timelockExpiry": "...", "transaction": {"to": "0x...", "data": "0x..."}}}
+
+# Optional: specify refund destination
+cash refund --address
+```
+
+### Pay x402-Protected APIs
+
+```bash
+# Fetch a URL — if 402, auto-swap BTC to USDC and pay
+cash fetch https://public.zapper.xyz/x402/token-price --method POST --body '{"address":"0x...","chainId":137}'
+# -> {"ok": true, "data": { ... response from paid API ... }}
+
+# GET request (default method)
+cash fetch https://search.reversesandbox.com/web/search?q=bitcoin
+# -> {"ok": true, "data": "...search results..."}
+
+# Custom headers
+cash fetch https://api.example.com/paid --header "Accept:application/json"
+```
+
+Flow: request → 402 detected → parse payment requirements (chain, amount, asset) → swap BTC to USDC on the required chain (Polygon, Arbitrum, Ethereum) via LendaSwap → sign EIP-3009 authorization via ECDSA → retry with x402 payment proof.
+
+The agent holds BTC as treasury. USDC is swapped on demand, authorized to the facilitator, and never held as a reserve.
+
+### Daemon (Swap Monitoring)
+
+The daemon runs in the background to automatically claim Lightning HTLCs and monitor LendaSwap swaps. It is **auto-started by `cash init`**. Use these commands to manage it manually:
+
+```bash
+# Start the daemon
+cash start
+# -> {"ok": true, "data": {"started": true, "pid": 12345, "port": 3457}}
+# (if already running: {"ok": true, "data": {"started": false, "reason": "already_running", "pid": 12345, "port": 3457}})
+
+# Check daemon and session status
+cash status
+# -> {"ok": true, "data": {"session": "active", "sessionExpiresAt": 1739..., "sessionRemainingSeconds": 3200, "daemon": {"running": true, "pid": 12345, "port": 3457, "detail": {...}}}}
+
+# List pending swaps
+cash swaps --pending
+
+# Stop the daemon
+cash stop
+# -> {"ok": true, "data": {"stopped": true, "pid": 12345}}
+# (if not running: {"ok": true, "data": {"stopped": false, "reason": "not_running"}})
+```
+
+### Swap Event Webhooks
+
+Register a webhook URL to receive notifications when the daemon claims, refunds, or fails a swap. Webhooks are in-memory (re-register after daemon restart).
+
+```bash
+# Register for swap event notifications (daemon HTTP API, default port 3457)
+curl -X POST http://127.0.0.1:3457/notify/register \
+ -H 'content-type: application/json' \
+ -d '{"url": "https://my-bot/webhook", "events": ["swap.claimed", "swap.refunded", "swap.failed"]}'
+# -> {"id": "", "url": "https://my-bot/webhook", "events": ["swap.claimed", "swap.refunded", "swap.failed"]}
+
+# List active webhooks
+curl http://127.0.0.1:3457/notify
+# -> {"webhooks": [{"id": "...", "url": "...", "events": [...]}]}
+
+# Unregister a webhook
+curl -X DELETE http://127.0.0.1:3457/notify/
+# -> {"removed": true}
+```
+
+Webhook payload (POST to registered URL):
+
+```json
+{
+ "event": "swap.claimed",
+ "swapId": "abc123...",
+ "status": "completed",
+ "direction": "btc_to_stablecoin",
+ "sourceAmount": 100000,
+ "sourceToken": "btc_arkade",
+ "targetAmount": 10.5,
+ "targetToken": "usdc_pol",
+ "message": "Swap abc123... claimed successfully",
+ "timestamp": "2026-02-16T12:00:00Z"
+}
+```
+
+Events: `swap.claimed`, `swap.refunded`, `swap.failed`.
+
+## Output Format
+
+Success (stdout):
+
+```json
+{"ok": true, "data": { ... }}
+```
+
+Error (stderr):
+
+```json
+{"ok": false, "error": "description of what went wrong"}
+```
+
+## Currency & Network Matrix
+
+| Currency | Networks | Notes |
+| -------- | --------------------------- | ----------------------------------------- |
+| sats | onchain, lightning, arkade | Amount in satoshis (use `sats` not `btc`) |
+| btc | onchain, lightning, arkade | Amount in BTC (e.g. 0.001) |
+| usdt | polygon, ethereum, arbitrum | |
+| usdc | polygon, ethereum, arbitrum | |
+
+## Swap Status Lifecycle
+
+| Status | Meaning |
+| ------------------ | ------------------------------ |
+| pending | Swap created, awaiting funding |
+| awaiting_funding | Initial state |
+| funded | User has sent funds |
+| processing | Swap in progress |
+| completed | Swap done, claimed |
+| expired | Timelock expired |
+| refunded | Funds returned |
+| failed | Swap failed |
+
+Directions: `btc_to_stablecoin` or `stablecoin_to_btc`.
+
+Token identifiers: `btc_arkade`, `usdc_pol`, `usdc_eth`, `usdc_arb`, `usdt0_pol`, `usdt_eth`, `usdt_arb`.
+
+## Agent Tips
+
+All output is JSON — pipe through `jq` to extract specific fields:
+
+```bash
+# Get just the swap status
+cash swap | jq .data.status
+
+# Get total balance in sats
+cash balance | jq .data.total
+
+# Get offchain available balance
+cash balance | jq .data.offchain.available
+
+# Check if daemon is running
+cash status | jq .data.daemon.running
+
+# Check session state (active or expired)
+cash status | jq .data.session
+
+# List only pending swap IDs
+cash swaps --pending | jq '[.data.lendaswap.pending[].id]'
+
+# Get the payment bolt11 invoice
+cash receive --amount 50000 --currency sats --where lightning | jq -r .data.bolt11
+
+# Get the ark address for receiving
+cash receive --amount 100000 --currency sats --where arkade | jq -r .data.address
+
+# Get the payment URL for stablecoin receive
+cash receive --amount 10 --currency usdc | jq -r .data.paymentUrl
+
+# Check if a command succeeded
+cash send ... && echo "sent" || echo "failed"
+
+# Pay for an x402-protected API
+cash fetch https://public.zapper.xyz/x402/token-price --method POST --body '{"address":"0x...","chainId":137}'
+```
+
+Common workflow for paying an x402 API:
+
+```bash
+# 1. Fetch the API — if 402, agent auto-swaps BTC to USDC and pays
+cash fetch https://search.reversesandbox.com/web/search?q=bitcoin
+
+# Output is the API response (JSON or text)
+```
+
+Common workflow for monitoring a stablecoin swap:
+
+```bash
+# 1. Initiate the swap
+cash send --amount 10 --currency usdc --where polygon --to 0x...
+
+# 2. Poll status until completed (daemon does this automatically)
+cash swap | jq .data.status
+
+# 3. If expired, refund
+cash refund
+```
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 0000000..a6d6616
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "clw-cash",
+ "version": "0.1.21",
+ "description": "Bitcoin & Stablecoin agent wallet CLI. Keys held in a secure enclave.",
+ "type": "module",
+ "bin": {
+ "clw-cash": "./dist/index.js",
+ "cash": "./dist/index.js"
+ },
+ "files": [
+ "dist",
+ "SKILL.md"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "prepublishOnly": "tsup",
+ "typecheck": "tsc --noEmit",
+ "dev": "tsx src/index.ts"
+ },
+ "dependencies": {
+ "@arkade-os/boltz-swap": "^0.2.18",
+ "@arkade-os/sdk": "^0.3.12",
+ "@lendasat/lendaswap-sdk-pure": "^0.0.2",
+ "@noble/hashes": "^2.0.1",
+ "@x402/evm": "^2.3.1",
+ "@x402/fetch": "^2.3.0",
+ "better-sqlite3": "^12.6.2",
+ "minimist": "^1.2.8",
+ "viem": "^2.45.3"
+ },
+ "devDependencies": {
+ "@clw-cash/sdk": "workspace:*",
+ "@clw-cash/skills": "workspace:*",
+ "@types/minimist": "^1.2.5",
+ "@types/node": "^22.13.4",
+ "tsup": "^8.5.1",
+ "tsx": "^4.19.3",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/cli/src/authMonitor.ts b/cli/src/authMonitor.ts
new file mode 100644
index 0000000..fb7699e
--- /dev/null
+++ b/cli/src/authMonitor.ts
@@ -0,0 +1,177 @@
+import { loadConfig, saveConfig } from "./config.js";
+
+const POLL_INTERVAL_MS = 2_000;
+const POLL_TIMEOUT_MS = 120_000;
+
+export interface PendingAuth {
+ challengeId: string;
+ deepLink: string | null;
+ apiBaseUrl: string;
+ /** Telegram bot token to send the reply as */
+ botToken: string;
+ /** Chat ID to send the reply to */
+ chatId: number;
+ /** Message ID to reply to */
+ messageId: number;
+ createdAt: number;
+}
+
+export class AuthMonitor {
+ private pending: PendingAuth | null = null;
+ private timer: ReturnType | null = null;
+ private polling = false;
+
+ /** Start watching a new auth challenge. Only one at a time. */
+ watch(auth: PendingAuth): void {
+ this.stop();
+ this.pending = auth;
+ this.timer = setInterval(() => void this.poll(), POLL_INTERVAL_MS);
+ void this.poll();
+ }
+
+ stop(): void {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ this.pending = null;
+ }
+
+ getStatus(): { status: "idle" } | { status: "polling"; challengeId: string } {
+ if (!this.pending) return { status: "idle" };
+ return { status: "polling", challengeId: this.pending.challengeId };
+ }
+
+ private async poll(): Promise {
+ if (this.polling || !this.pending) return;
+ this.polling = true;
+
+ const auth = this.pending;
+
+ try {
+ // Check timeout
+ if (Date.now() - auth.createdAt > POLL_TIMEOUT_MS) {
+ console.error(`[auth-monitor] challenge ${auth.challengeId} timed out`);
+ await this.sendTelegramReply(auth, "Login timed out. Please try again.");
+ this.stop();
+ return;
+ }
+
+ const res = await fetch(`${auth.apiBaseUrl}/v1/auth/verify`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ challenge_id: auth.challengeId }),
+ });
+
+ // 202 = not yet resolved
+ if (res.status === 202) return;
+
+ if (res.ok) {
+ const session = (await res.json()) as {
+ token: string;
+ expires_in: number;
+ user: { id: string; telegram_user_id: string; status: string };
+ };
+
+ // Save the new token to config
+ const config = loadConfig();
+ config.sessionToken = session.token;
+ saveConfig(config);
+
+ // Restore or recover identity
+ if (config.identityId && config.publicKey) {
+ try {
+ const restoreRes = await fetch(
+ `${auth.apiBaseUrl}/v1/identities/${config.identityId}/restore`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${session.token}`,
+ },
+ body: JSON.stringify({ public_key: config.publicKey }),
+ }
+ );
+ if (!restoreRes.ok) {
+ console.error(`[auth-monitor] identity restore failed: ${await restoreRes.text()}`);
+ }
+ } catch (err) {
+ console.error(`[auth-monitor] identity restore error: ${err}`);
+ }
+ } else {
+ // Config wiped — try to recover existing identity from server
+ try {
+ const listRes = await fetch(`${auth.apiBaseUrl}/v1/identities`, {
+ headers: { authorization: `Bearer ${session.token}` },
+ });
+ if (listRes.ok) {
+ const data = (await listRes.json()) as { items: Array<{ id: string; public_key: string }> };
+ if (data.items.length > 0) {
+ const identity = data.items[0];
+ config.identityId = identity.id;
+ config.publicKey = identity.public_key;
+ saveConfig(config);
+
+ const restoreRes = await fetch(
+ `${auth.apiBaseUrl}/v1/identities/${identity.id}/restore`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${session.token}`,
+ },
+ body: JSON.stringify({ public_key: identity.public_key }),
+ }
+ );
+ if (restoreRes.ok) {
+ console.error(`[auth-monitor] recovered identity ${identity.id}`);
+ } else {
+ console.error(`[auth-monitor] identity recovery restore failed: ${await restoreRes.text()}`);
+ }
+ }
+ }
+ } catch (err) {
+ console.error(`[auth-monitor] identity recovery error: ${err}`);
+ }
+ }
+
+ console.error(`[auth-monitor] login successful for user ${session.user.id}`);
+ await this.sendTelegramReply(auth, "Connected, welcome back!");
+ this.stop();
+ return;
+ }
+
+ // Unexpected error
+ const text = await res.text();
+ console.error(`[auth-monitor] verify error: ${text}`);
+ await this.sendTelegramReply(auth, "Login failed. Please try again.");
+ this.stop();
+ } catch (err) {
+ console.error(`[auth-monitor] poll error: ${err}`);
+ } finally {
+ this.polling = false;
+ }
+ }
+
+ private async sendTelegramReply(auth: PendingAuth, text: string): Promise {
+ try {
+ const res = await fetch(
+ `https://api.telegram.org/bot${auth.botToken}/sendMessage`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ chat_id: auth.chatId,
+ text,
+ reply_to_message_id: auth.messageId,
+ }),
+ }
+ );
+ if (!res.ok) {
+ console.error(`[auth-monitor] telegram reply failed: ${await res.text()}`);
+ }
+ } catch (err) {
+ console.error(`[auth-monitor] telegram reply error: ${err}`);
+ }
+ }
+}
diff --git a/cli/src/commands/balance.ts b/cli/src/commands/balance.ts
new file mode 100644
index 0000000..a9ad822
--- /dev/null
+++ b/cli/src/commands/balance.ts
@@ -0,0 +1,14 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess } from "../output.js";
+import { getDaemonUrl, daemonGet } from "../daemonClient.js";
+
+export async function handleBalance(ctx: CashContext): Promise {
+ // Proxy through daemon when running
+ if (getDaemonUrl()) {
+ const balance = await daemonGet("/balance");
+ return outputSuccess(balance);
+ }
+
+ const balance = await ctx.bitcoin.getBalance();
+ return outputSuccess(balance);
+}
diff --git a/cli/src/commands/claim.ts b/cli/src/commands/claim.ts
new file mode 100644
index 0000000..db510ae
--- /dev/null
+++ b/cli/src/commands/claim.ts
@@ -0,0 +1,21 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess, outputError } from "../output.js";
+import { getDaemonUrl, daemonPost } from "../daemonClient.js";
+import type { ParsedArgs } from "minimist";
+
+export async function handleClaim(ctx: CashContext, args: ParsedArgs): Promise {
+ const swapId = (args._[1] as string) || (args.id as string);
+
+ if (!swapId) {
+ return outputError("Missing swap ID. Usage: cash claim ");
+ }
+
+ // Proxy through daemon when running
+ if (getDaemonUrl()) {
+ const result = await daemonPost(`/swaps/${swapId}/claim`, {});
+ return outputSuccess(result);
+ }
+
+ const result = await ctx.swap.claimSwap(swapId);
+ return outputSuccess(result);
+}
diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts
new file mode 100644
index 0000000..932f57f
--- /dev/null
+++ b/cli/src/commands/config.ts
@@ -0,0 +1,29 @@
+import { loadConfigWithSources, getSessionStatus } from "../config.js";
+import { getPort } from "../daemon.js";
+import { outputSuccess } from "../output.js";
+import { join } from "node:path";
+import { homedir } from "node:os";
+
+export async function handleConfig(): Promise {
+ const configWithSources = loadConfigWithSources();
+ const session = getSessionStatus(configWithSources.sessionToken.value);
+
+ const apiBaseUrl = configWithSources.apiBaseUrl.value;
+ const payBaseUrl = apiBaseUrl.replace("api.", "pay.");
+
+ const result: Record = {
+ apiBaseUrl: { value: apiBaseUrl, source: configWithSources.apiBaseUrl.source },
+ payBaseUrl,
+ arkServerUrl: { value: configWithSources.arkServerUrl.value, source: configWithSources.arkServerUrl.source },
+ network: { value: configWithSources.network.value, source: configWithSources.network.source },
+ identityId: { value: configWithSources.identityId.value, source: configWithSources.identityId.source },
+ publicKey: { value: configWithSources.publicKey.value, source: configWithSources.publicKey.source },
+ daemonPort: getPort(),
+ configFile: join(homedir(), ".clw-cash", "config.json"),
+ dataDir: join(homedir(), ".clw-cash", "data"),
+ session: session.active ? "active" : "expired",
+ sessionExpiresAt: session.expiresAt,
+ };
+
+ return outputSuccess(result);
+}
diff --git a/cli/src/commands/fetch.ts b/cli/src/commands/fetch.ts
new file mode 100644
index 0000000..547d25d
--- /dev/null
+++ b/cli/src/commands/fetch.ts
@@ -0,0 +1,284 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess, outputError } from "../output.js";
+import type { ParsedArgs } from "minimist";
+import type { RemoteSignerIdentity } from "@clw-cash/sdk";
+import type { StablecoinToken, EvmChain } from "@clw-cash/skills";
+
+// CAIP-2 network ID → LendaSwap chain/token mapping
+const CAIP2_TO_CHAIN: Record = {
+ "eip155:137": { chain: "polygon", token: "usdc_pol" },
+ "eip155:42161": { chain: "arbitrum", token: "usdc_arb" },
+ "eip155:1": { chain: "ethereum", token: "usdc_eth" },
+};
+
+// Bitcoin CAIP-2 networks (future: Lightning, Arkade, on-chain)
+const BITCOIN_NETWORKS = new Set([
+ "bip122:000000000019d6689c085ae165831e93", // Bitcoin mainnet
+ "lightning:mainnet",
+ "arkade:mainnet",
+]);
+
+/**
+ * cash fetch [--method GET] [--body ] [--header key:value]
+ *
+ * Makes an HTTP request. If the server responds with 402 Payment Required,
+ * automatically handles payment via x402 protocol:
+ *
+ * EVM payments (Polygon, Arbitrum, Ethereum):
+ * 1. Parse 402 requirements (chain, amount, asset)
+ * 2. Swap BTC → USDC on the required chain via LendaSwap (gasless arkadeToEvm)
+ * 3. Sign transferWithAuthorization via ECDSA through the enclave
+ * 4. Retry the request with payment proof
+ *
+ * Bitcoin payments (future: Lightning, Arkade, on-chain):
+ * 1. Parse 402 requirements (bolt11 invoice, address, etc.)
+ * 2. Pay directly with sats — no swap needed
+ */
+export async function handleFetch(
+ ctx: CashContext,
+ args: ParsedArgs
+): Promise {
+ const url = args._[1] as string | undefined;
+ if (!url) {
+ return outputError("Usage: cash fetch [--method GET] [--body ] [--header key:value]");
+ }
+
+ const method = (args.method as string) || "GET";
+ const bodyStr = args.body as string | undefined;
+ const rawHeaders = args.header as string | string[] | undefined;
+
+ // Parse headers
+ const headers: Record = {};
+ if (rawHeaders) {
+ const headerList = Array.isArray(rawHeaders) ? rawHeaders : [rawHeaders];
+ for (const h of headerList) {
+ const colonIdx = h.indexOf(":");
+ if (colonIdx === -1) return outputError(`Invalid header format: ${h}. Expected key:value`);
+ headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
+ }
+ }
+
+ // First, try the request without payment
+ const fetchOpts: RequestInit = { method, headers };
+ if (bodyStr && method !== "GET") {
+ fetchOpts.body = bodyStr;
+ if (!headers["content-type"]) {
+ headers["content-type"] = "application/json";
+ }
+ }
+
+ const response = await fetch(url, fetchOpts);
+
+ // If not 402, return the response directly
+ if (response.status !== 402) {
+ const contentType = response.headers.get("content-type") || "";
+ let data: unknown;
+ if (contentType.includes("json")) {
+ data = await response.json();
+ } else {
+ data = await response.text();
+ }
+ if (!response.ok) {
+ return outputError(`HTTP ${response.status}: ${response.statusText}`, data);
+ }
+ return outputSuccess(data);
+ }
+
+ // 402 Payment Required — parse requirements to determine chain and amount
+ console.error("[x402] Payment required, parsing requirements...");
+ const paymentRequired = await parsePaymentRequired(response);
+ if (!paymentRequired) {
+ return outputError("402 Payment Required but could not parse payment requirements");
+ }
+
+ // Find the best payment option we can fulfill
+ const evmOption = selectEvmOption(paymentRequired.accepts);
+ const btcOption = selectBitcoinOption(paymentRequired.accepts);
+
+ if (btcOption) {
+ // Future: pay directly with sats via Lightning/Arkade/on-chain
+ console.error(`[x402] Bitcoin payment option found: ${btcOption.network} (${btcOption.amount} sats)`);
+ return outputError(
+ "Bitcoin x402 payments not yet implemented. " +
+ "This will support Lightning invoices, Arkade vtxo transfers, and on-chain payments. " +
+ `Requested: ${btcOption.network}, amount: ${btcOption.amount}`
+ );
+ }
+
+ if (!evmOption) {
+ const networks = paymentRequired.accepts.map((a: any) => a.network).join(", ");
+ return outputError(
+ `No supported payment method found. Server accepts: [${networks}]. ` +
+ `Supported EVM chains: ${Object.keys(CAIP2_TO_CHAIN).join(", ")}. ` +
+ "Bitcoin payments coming soon."
+ );
+ }
+
+ const chainInfo = CAIP2_TO_CHAIN[evmOption.network];
+ if (!chainInfo) {
+ return outputError(
+ `Chain ${evmOption.network} is not yet supported for BTC→USDC swaps. ` +
+ `Supported: ${Object.keys(CAIP2_TO_CHAIN).join(", ")}`
+ );
+ }
+
+ // Amount in USDC atomic units (6 decimals)
+ const usdcAmount = parseInt(evmOption.amount, 10);
+ const usdcHuman = (usdcAmount / 1e6).toFixed(6);
+ console.error(`[x402] Payment: ${usdcHuman} USDC on ${chainInfo.chain} (${evmOption.network})`);
+
+ // Lazy-load viem and x402 dependencies
+ const [viemAccounts, viemMod, x402FetchMod, x402EvmMod] = await Promise.all([
+ import("viem/accounts"),
+ import("viem"),
+ import("@x402/fetch"),
+ import("@x402/evm/exact/client"),
+ ]);
+
+ // Create a custom viem account backed by remote ECDSA signing via enclave
+ const evmAddress = ctx.identity.getEvmAddress() as `0x${string}`;
+ console.error(`[x402] Agent EVM address: ${evmAddress}`);
+
+ const customAccount = viemAccounts.toAccount({
+ address: evmAddress,
+ async signMessage({ message }) {
+ const msgBytes = typeof message === "string"
+ ? new TextEncoder().encode(message)
+ : message.raw instanceof Uint8Array
+ ? message.raw
+ : viemMod.hexToBytes(message.raw);
+ const hash = viemMod.keccak256(msgBytes);
+ return signWithIdentity(ctx.identity, hash);
+ },
+ async signTransaction() {
+ throw new Error("Transaction signing not supported — agent operates gaslessly");
+ },
+ async signTypedData(params) {
+ const hash = viemMod.hashTypedData(params as any);
+ return signWithIdentity(ctx.identity, hash);
+ },
+ });
+
+ // Swap BTC → USDC on the required chain (gasless via LendaSwap arkadeToEvm)
+ console.error(`[x402] Swapping BTC → ${usdcHuman} USDC on ${chainInfo.chain}...`);
+ try {
+ const swapResult = await ctx.swap.swapBtcToStablecoin({
+ targetAddress: evmAddress,
+ targetToken: chainInfo.token,
+ targetChain: chainInfo.chain,
+ targetAmount: usdcAmount,
+ });
+ console.error(`[x402] Swap initiated: ${swapResult.swapId}, waiting for completion...`);
+
+ // Poll for swap completion
+ await waitForSwapCompletion(ctx, swapResult.swapId);
+ console.error("[x402] USDC delivered, proceeding with payment...");
+ } catch (err: any) {
+ return outputError(`BTC→USDC swap failed: ${err.message}`);
+ }
+
+ // Register EVM scheme with wildcard (all EVM chains)
+ const client = new x402FetchMod.x402Client();
+ x402EvmMod.registerExactEvmScheme(client, { signer: customAccount as any });
+
+ // Retry the request — x402 handles the 402 negotiation and payment signing
+ const fetchWithPayment = x402FetchMod.wrapFetchWithPayment(fetch, client);
+
+ console.error("[x402] Retrying request with payment...");
+ const paidResponse = await fetchWithPayment(url, fetchOpts);
+
+ const paidContentType = paidResponse.headers.get("content-type") || "";
+ let paidData: unknown;
+ if (paidContentType.includes("json")) {
+ paidData = await paidResponse.json();
+ } else {
+ paidData = await paidResponse.text();
+ }
+
+ if (!paidResponse.ok) {
+ return outputError(`HTTP ${paidResponse.status} after payment: ${paidResponse.statusText}`, paidData);
+ }
+
+ return outputSuccess(paidData);
+}
+
+// ── Payment requirement parsing ────────────────────────────
+
+interface PaymentAccept {
+ scheme: string;
+ network: string;
+ amount: string;
+ asset: string;
+ payTo: string;
+ maxTimeoutSeconds?: number;
+ extra?: Record;
+}
+
+interface PaymentRequired {
+ x402Version: number;
+ accepts: PaymentAccept[];
+}
+
+async function parsePaymentRequired(response: Response): Promise {
+ // V2: base64-encoded JSON in PAYMENT-REQUIRED header
+ const header = response.headers.get("payment-required");
+ if (header) {
+ try {
+ const decoded = JSON.parse(atob(header));
+ if (decoded.accepts) return decoded;
+ } catch { /* fall through to body */ }
+ }
+
+ // V1: JSON body with accepts array
+ try {
+ const body = await response.json();
+ if (body.accepts) return body;
+ } catch { /* not parseable */ }
+
+ return null;
+}
+
+function selectEvmOption(accepts: PaymentAccept[]): PaymentAccept | null {
+ // Prefer chains we can swap to (Polygon, Arbitrum, Ethereum)
+ // Then fall back to any EVM chain (e.g. Base) with a warning
+ const supported = accepts.filter(a => a.network.startsWith("eip155:") && CAIP2_TO_CHAIN[a.network]);
+ if (supported.length > 0) return supported[0];
+
+ const anyEvm = accepts.filter(a => a.network.startsWith("eip155:"));
+ if (anyEvm.length > 0) return anyEvm[0];
+
+ return null;
+}
+
+function selectBitcoinOption(accepts: PaymentAccept[]): PaymentAccept | null {
+ return accepts.find(a => BITCOIN_NETWORKS.has(a.network) || a.network.startsWith("bip122:")) || null;
+}
+
+// ── Swap completion polling ────────────────────────────────
+
+async function waitForSwapCompletion(ctx: CashContext, swapId: string): Promise {
+ const MAX_WAIT_MS = 5 * 60 * 1000; // 5 minutes
+ const POLL_INTERVAL_MS = 3000;
+ const start = Date.now();
+
+ while (Date.now() - start < MAX_WAIT_MS) {
+ const info = await ctx.swap.getSwapStatus(swapId);
+ if (info.status === "completed") return;
+ if (info.status === "failed" || info.status === "expired" || info.status === "refunded") {
+ throw new Error(`Swap ${swapId} ended with status: ${info.status}`);
+ }
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
+ }
+ throw new Error(`Swap ${swapId} timed out after ${MAX_WAIT_MS / 1000}s`);
+}
+
+// ── ECDSA signing helper ───────────────────────────────────
+
+async function signWithIdentity(
+ identity: RemoteSignerIdentity,
+ hash: `0x${string}`
+): Promise<`0x${string}`> {
+ const digestHex = hash.slice(2);
+ const result = await identity.signEcdsaDigest(digestHex);
+ return `0x${result.r}${result.s}${result.v.toString(16).padStart(2, "0")}` as `0x${string}`;
+}
diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts
new file mode 100644
index 0000000..5cb25c4
--- /dev/null
+++ b/cli/src/commands/init.ts
@@ -0,0 +1,128 @@
+import { ClwApiClient } from "@clw-cash/sdk";
+import { loadConfig, saveConfig, getSessionStatus, type CashConfig } from "../config.js";
+import { outputSuccess } from "../output.js";
+import { getPort, startDaemonInBackground } from "../daemon.js";
+import type { ParsedArgs } from "minimist";
+
+async function authenticate(config: CashConfig): Promise {
+ const challengeRes = await fetch(`${config.apiBaseUrl}/v1/auth/challenge`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ if (!challengeRes.ok) {
+ throw new Error(`Challenge failed: ${await challengeRes.text()}`);
+ }
+
+ const challenge = (await challengeRes.json()) as {
+ challenge_id: string;
+ deep_link: string | null;
+ };
+
+ if (challenge.deep_link) {
+ console.error(`Open this link to authenticate:\n\n ${challenge.deep_link}\n`);
+ console.error("Waiting for confirmation...");
+ }
+
+ // Poll verify until resolved (120s timeout)
+ const deadline = Date.now() + 120_000;
+ while (Date.now() < deadline) {
+ const verifyRes = await fetch(`${config.apiBaseUrl}/v1/auth/verify`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ challenge_id: challenge.challenge_id }),
+ });
+
+ // 202 = not yet resolved, keep polling (must check before .ok since 202 is "ok")
+ if (verifyRes.status === 202) {
+ await new Promise((r) => setTimeout(r, 2000));
+ continue;
+ }
+
+ if (verifyRes.ok) {
+ const session = (await verifyRes.json()) as {
+ token: string;
+ expires_in: number;
+ };
+ return session.token;
+ }
+
+ throw new Error(`Verify failed: ${await verifyRes.text()}`);
+ }
+
+ throw new Error("Login timed out");
+}
+
+async function restoreIdentity(config: CashConfig): Promise {
+ const res = await fetch(`${config.apiBaseUrl}/v1/identities/${config.identityId}/restore`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${config.sessionToken}`,
+ },
+ body: JSON.stringify({ public_key: config.publicKey }),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Failed to restore identity: ${text}`);
+ }
+}
+
+export async function handleInit(args: ParsedArgs): Promise {
+ const config = loadConfig({
+ apiBaseUrl: args["api-url"] as string | undefined,
+ sessionToken: args.token as string | undefined,
+ arkServerUrl: args["ark-server"] as string | undefined,
+ network: args.network as string | undefined,
+ });
+
+ // Auto-login if no token or token expired
+ if (!config.sessionToken || !getSessionStatus(config.sessionToken).active) {
+ config.sessionToken = await authenticate(config);
+ }
+
+ if (!config.identityId || !config.publicKey) {
+ // No identity in local config — check server for existing ones
+ const existing = await ClwApiClient.listIdentities(config.apiBaseUrl, config.sessionToken);
+
+ if (existing.items.length > 0) {
+ // Recover the most recent active identity
+ const identity = existing.items[0];
+ config.identityId = identity.id;
+ config.publicKey = identity.public_key;
+ await restoreIdentity(config);
+ console.error(`Recovered existing identity: ${identity.id}`);
+ } else {
+ // No existing identities — create a new one
+ const identity = await ClwApiClient.createIdentity(
+ config.apiBaseUrl,
+ config.sessionToken
+ );
+ config.identityId = identity.id;
+ config.publicKey = identity.public_key;
+ }
+ } else {
+ // Identity exists in config — ensure it's registered on the API (survives server restarts)
+ await restoreIdentity(config);
+ }
+
+ saveConfig(config);
+
+ // Auto-start the daemon for swap monitoring
+ let daemon: { pid: number; port: number } | null = null;
+ try {
+ const port = getPort();
+ const { pid } = await startDaemonInBackground(port);
+ daemon = { pid, port };
+ } catch {
+ // Non-fatal — daemon can be started manually with `cash start`
+ }
+
+ return outputSuccess({
+ message: "Config saved to ~/.clw-cash/config.json",
+ identityId: config.identityId,
+ publicKey: config.publicKey,
+ network: config.network,
+ daemon,
+ });
+}
diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts
new file mode 100644
index 0000000..76ea387
--- /dev/null
+++ b/cli/src/commands/login.ts
@@ -0,0 +1,179 @@
+import { loadConfig, saveConfig } from "../config.js";
+import { outputSuccess, outputError } from "../output.js";
+import { getDaemonStatus, stopDaemon, startDaemonInBackground, getPort } from "../daemon.js";
+import { daemonPost } from "../daemonClient.js";
+
+const POLL_INTERVAL_MS = 2000;
+const POLL_TIMEOUT_MS = 120_000;
+
+export async function handleLogin(argv?: Record): Promise {
+ // Non-blocking mode: delegate to daemon for background polling + Telegram reply
+ if (argv?.start) {
+ const botToken = argv["bot-token"] as string;
+ const chatId = Number(argv["chat-id"]);
+ const messageId = Number(argv["message-id"]);
+
+ if (!botToken || !chatId || !messageId) {
+ return outputError("--start requires --bot-token, --chat-id, and --message-id");
+ }
+
+ const status = getDaemonStatus();
+ if (!status.running) {
+ return outputError("Daemon is not running. Run 'cash start' first.");
+ }
+
+ const result = (await daemonPost("/auth/start", {
+ botToken,
+ chatId,
+ messageId,
+ })) as { challengeId: string; deepLink: string | null };
+
+ return outputSuccess({
+ message: "Auth started. Daemon will poll and reply via Telegram when done.",
+ challengeId: result.challengeId,
+ deepLink: result.deepLink,
+ });
+ }
+
+ // Original blocking mode
+ const config = loadConfig();
+
+ if (!config.apiBaseUrl) {
+ return outputError("Not initialized. Run 'cash init' first.");
+ }
+
+ // Step 1: Request a challenge
+ const challengeRes = await fetch(`${config.apiBaseUrl}/v1/auth/challenge`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ if (!challengeRes.ok) {
+ const text = await challengeRes.text();
+ return outputError(`Challenge request failed: ${text}`);
+ }
+
+ const challenge = (await challengeRes.json()) as {
+ challenge_id: string;
+ expires_at: string;
+ deep_link: string | null;
+ };
+
+ if (challenge.deep_link) {
+ console.error(`Open this link to authenticate:\n\n ${challenge.deep_link}\n`);
+ console.error("Waiting for confirmation...");
+ } else {
+ console.error(`Challenge ID: ${challenge.challenge_id}`);
+ console.error("Waiting for confirmation (test mode)...");
+ }
+
+ // Step 2: Poll verify until resolved or timeout
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
+
+ while (Date.now() < deadline) {
+ const verifyRes = await fetch(`${config.apiBaseUrl}/v1/auth/verify`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ challenge_id: challenge.challenge_id }),
+ });
+
+ // 202 = not yet resolved, keep polling (must check before .ok since 202 is "ok")
+ if (verifyRes.status === 202) {
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
+ continue;
+ }
+
+ if (verifyRes.ok) {
+ const session = (await verifyRes.json()) as {
+ token: string;
+ expires_in: number;
+ user: { id: string; telegram_user_id: string; status: string };
+ };
+
+ config.sessionToken = session.token;
+ saveConfig(config);
+
+ // Restore or recover identity
+ if (config.identityId && config.publicKey) {
+ const restoreRes = await fetch(
+ `${config.apiBaseUrl}/v1/identities/${config.identityId}/restore`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${session.token}`,
+ },
+ body: JSON.stringify({ public_key: config.publicKey }),
+ }
+ );
+ if (!restoreRes.ok) {
+ const text = await restoreRes.text();
+ console.error(`Warning: identity restore failed: ${text}`);
+ }
+ } else {
+ // Config wiped — try to recover existing identity from server
+ try {
+ const listRes = await fetch(`${config.apiBaseUrl}/v1/identities`, {
+ headers: { authorization: `Bearer ${session.token}` },
+ });
+ if (listRes.ok) {
+ const data = (await listRes.json()) as { items: Array<{ id: string; public_key: string }> };
+ if (data.items.length > 0) {
+ const identity = data.items[0];
+ config.identityId = identity.id;
+ config.publicKey = identity.public_key;
+ saveConfig(config);
+
+ const restoreRes = await fetch(
+ `${config.apiBaseUrl}/v1/identities/${identity.id}/restore`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${session.token}`,
+ },
+ body: JSON.stringify({ public_key: identity.public_key }),
+ }
+ );
+ if (restoreRes.ok) {
+ console.error(`Recovered existing identity: ${identity.id}`);
+ } else {
+ console.error(`Warning: identity recovery restore failed: ${await restoreRes.text()}`);
+ }
+ }
+ }
+ } catch (err) {
+ console.error(`Warning: identity recovery failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+
+ // Restart daemon if it was running (so it picks up the new token)
+ const daemonStatus = getDaemonStatus();
+ let daemon: { restarted: boolean; pid?: number; port?: number } = { restarted: false };
+ if (daemonStatus.running) {
+ console.error("Restarting daemon with new session...");
+ await stopDaemon();
+ try {
+ const port = getPort();
+ const { pid } = await startDaemonInBackground(port);
+ daemon = { restarted: true, pid, port };
+ } catch (err) {
+ console.error(`Warning: daemon restart failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+
+ return outputSuccess({
+ message: "Logged in successfully",
+ userId: session.user.id,
+ expiresIn: session.expires_in,
+ daemon,
+ });
+ }
+
+ const text = await verifyRes.text();
+ return outputError(`Verify failed: ${text}`);
+ }
+
+ return outputError("Login timed out. Please try again.");
+}
diff --git a/cli/src/commands/receive.ts b/cli/src/commands/receive.ts
new file mode 100644
index 0000000..ca8a064
--- /dev/null
+++ b/cli/src/commands/receive.ts
@@ -0,0 +1,144 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess, outputError } from "../output.js";
+import { getDaemonUrl, daemonPost } from "../daemonClient.js";
+import {
+ isValidCurrency,
+ isValidWhere,
+ validateCurrencyWhere,
+ toStablecoinToken,
+ resolveCurrency,
+ parseBtcAmount,
+} from "../utils/token.js";
+import type { CashConfig } from "../config.js";
+import type { ParsedArgs } from "minimist";
+
+export async function handleReceive(
+ ctx: CashContext,
+ args: ParsedArgs,
+ config: CashConfig
+): Promise {
+ const amountStr = args.amount as string | undefined;
+ const currency = args.currency as string | undefined;
+ const where = args.where as string | undefined;
+
+ if (!currency) {
+ return outputError("Missing --currency ");
+ }
+
+ if (!isValidCurrency(currency)) {
+ return outputError(
+ `Invalid currency: ${currency}. Expected: btc, sats, usdt, usdc`
+ );
+ }
+
+ const resolved = resolveCurrency(currency);
+
+ // Stablecoins: --where is optional (sender picks chain on web page)
+ if (resolved !== "btc" && !where) {
+ if (!amountStr) {
+ return outputError(
+ `Missing --amount <${currency}> (e.g. --amount 10 for 10 ${currency.toUpperCase()})`
+ );
+ }
+ const amount = parseFloat(amountStr);
+ if (isNaN(amount) || amount <= 0) {
+ return outputError(`Invalid amount: ${amountStr}`);
+ }
+
+ // Generate payment URL — sender chooses chain on the web page
+ const arkAddress = await ctx.bitcoin.getArkAddress();
+ const payBaseUrl = config.apiBaseUrl.replace("api.", "pay.");
+ const paymentUrl = `${payBaseUrl}?amount=${amount}&to=${arkAddress}¤cy=${currency}`;
+
+ return outputSuccess({
+ paymentUrl,
+ amount,
+ currency,
+ targetAddress: arkAddress,
+ });
+ }
+
+ if (!where) {
+ return outputError("Missing --where ");
+ }
+
+ if (!isValidWhere(where)) {
+ return outputError(
+ `Invalid where: ${where}. Expected: onchain, lightning, arkade, polygon, arbitrum, ethereum`
+ );
+ }
+
+ const validationError = validateCurrencyWhere(resolved, where);
+ if (validationError) {
+ return outputError(validationError);
+ }
+
+ // Amount is required for lightning invoices and stablecoin swaps
+ if (!amountStr && (where === "lightning" || resolved !== "btc")) {
+ return outputError(
+ resolved === "btc"
+ ? `Missing --amount (${currency === "sats" ? "sats" : "BTC"} required for lightning invoices)`
+ : `Missing --amount <${resolved}> (e.g. --amount 10 for 10 ${resolved.toUpperCase()})`
+ );
+ }
+
+ // Parse amount (optional for arkade/onchain)
+ let amount: number | undefined;
+ if (amountStr) {
+ if (resolved === "btc") {
+ const sats = parseBtcAmount(amountStr, currency);
+ if (sats === null) return outputError(`Invalid amount: ${amountStr}`);
+ amount = sats;
+ } else {
+ amount = parseFloat(amountStr);
+ if (isNaN(amount) || amount <= 0) return outputError(`Invalid amount: ${amountStr}`);
+ }
+ }
+
+ // Stablecoin receive: create swap via SDK (CLI owns preimage for claiming) and generate payment URL
+ if (resolved !== "btc") {
+ const sourceToken = toStablecoinToken(resolved, where);
+ const arkAddress = await ctx.bitcoin.getArkAddress();
+
+ const result = await ctx.swap.swapStablecoinToBtc({
+ sourceChain: where as import("@clw-cash/skills").EvmChain,
+ sourceToken: sourceToken as import("@clw-cash/skills").StablecoinToken,
+ sourceAmount: amount!,
+ targetAddress: arkAddress,
+ });
+
+ const payBaseUrl = config.apiBaseUrl.replace("api.", "pay.");
+ const paymentUrl = `${payBaseUrl}?id=${result.swapId}`;
+
+ return outputSuccess({
+ paymentUrl,
+ swapId: result.swapId,
+ amount,
+ token: sourceToken,
+ chain: where,
+ targetAddress: arkAddress,
+ });
+ }
+
+ // BTC routes — proxy through daemon when running
+ if (getDaemonUrl()) {
+ const body: Record = { amount, currency: resolved, where };
+ const result = await daemonPost("/receive", body);
+ return outputSuccess(result);
+ }
+
+ // BTC routes (no daemon — direct execution)
+ if (where === "lightning") {
+ const invoice = await ctx.lightning.createInvoice({ amount: amount! });
+ return outputSuccess(invoice);
+ }
+
+ if (where === "arkade") {
+ const address = await ctx.bitcoin.getArkAddress();
+ return outputSuccess({ address, type: "ark", amount });
+ }
+
+ // onchain
+ const address = await ctx.bitcoin.getBoardingAddress();
+ return outputSuccess({ address, type: "onchain", amount });
+}
diff --git a/cli/src/commands/refund.ts b/cli/src/commands/refund.ts
new file mode 100644
index 0000000..d561971
--- /dev/null
+++ b/cli/src/commands/refund.ts
@@ -0,0 +1,39 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess, outputError } from "../output.js";
+import { getDaemonUrl, daemonPost } from "../daemonClient.js";
+import type { ParsedArgs } from "minimist";
+
+export async function handleRefund(ctx: CashContext, args: ParsedArgs): Promise {
+ const swapId = (args._[1] as string) || (args.id as string);
+
+ if (!swapId) {
+ return outputError("Missing swap ID. Usage: cash refund [--address ]");
+ }
+
+ const destinationAddress = args.address as string | undefined;
+
+ // Proxy through daemon when running
+ if (getDaemonUrl()) {
+ const result = await daemonPost(`/swaps/${swapId}/refund`, { destinationAddress });
+ return outputSuccess(result);
+ }
+
+ // Check swap direction to determine refund method
+ const info = await ctx.swap.getSwapStatus(swapId);
+
+ if (info.direction === "stablecoin_to_btc") {
+ // EVM→BTC swap: return EVM refund call data for the user to broadcast
+ const callData = await ctx.swap.getEvmRefundCallData(swapId);
+ return outputSuccess({
+ type: "evm_refund",
+ swapId,
+ timelockExpired: callData.timelockExpired,
+ timelockExpiry: callData.timelockExpiry,
+ transaction: { to: callData.to, data: callData.data },
+ });
+ }
+
+ // BTC→Stablecoin swap: refund directly via SDK
+ const result = await ctx.swap.refundSwap(swapId, { destinationAddress });
+ return outputSuccess(result);
+}
diff --git a/cli/src/commands/send.ts b/cli/src/commands/send.ts
new file mode 100644
index 0000000..afdc8a7
--- /dev/null
+++ b/cli/src/commands/send.ts
@@ -0,0 +1,156 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess, outputError } from "../output.js";
+import { getDaemonUrl, daemonPost } from "../daemonClient.js";
+import { isBolt11, isBIP21, parseBIP21 } from "../utils/invoice.js";
+import {
+ isValidCurrency,
+ isValidWhere,
+ validateCurrencyWhere,
+ toStablecoinToken,
+ toEvmChain,
+ resolveCurrency,
+ parseBtcAmount,
+} from "../utils/token.js";
+import type { ParsedArgs } from "minimist";
+
+export async function handleSend(
+ ctx: CashContext,
+ args: ParsedArgs
+): Promise {
+ const positionals = args._.slice(1); // after "send"
+ const to = args.to as string | undefined;
+ const amountStr = args.amount as string | undefined;
+ const currency = args.currency as string | undefined;
+ const where = args.where as string | undefined;
+
+ // Positional invoice mode: cash send
+ if (positionals.length === 1 && !amountStr) {
+ const invoice = positionals[0] as string;
+
+ if (isBolt11(invoice)) {
+ if (getDaemonUrl()) {
+ const result = await daemonPost("/send", { currency: "btc", where: "lightning", to: invoice });
+ return outputSuccess(result);
+ }
+ const result = await ctx.lightning.payInvoice({ bolt11: invoice });
+ return outputSuccess(result);
+ }
+
+ if (isBIP21(invoice)) {
+ const parsed = parseBIP21(invoice);
+
+ // Prefer lightning if available in BIP21
+ if (parsed.lightning && isBolt11(parsed.lightning)) {
+ if (getDaemonUrl()) {
+ const result = await daemonPost("/send", { currency: "btc", where: "lightning", to: parsed.lightning });
+ return outputSuccess(result);
+ }
+ const result = await ctx.lightning.payInvoice({
+ bolt11: parsed.lightning,
+ });
+ return outputSuccess(result);
+ }
+
+ if (!parsed.amount) {
+ return outputError("BIP21 URI missing amount");
+ }
+
+ if (getDaemonUrl()) {
+ const result = await daemonPost("/send", { currency: "btc", where: "arkade", to: parsed.address, amount: parsed.amount });
+ return outputSuccess(result);
+ }
+ const result = await ctx.bitcoin.send({
+ address: parsed.address,
+ amount: parsed.amount,
+ });
+ return outputSuccess(result);
+ }
+
+ return outputError(
+ "Unrecognized invoice format. Expected bolt11 or bitcoin: URI"
+ );
+ }
+
+ // Flag mode: cash send --amount 100000 --currency btc --where arkade --to
+ if (!amountStr) {
+ return outputError("Missing --amount (sats for btc, units for usdt/usdc)");
+ }
+ if (!currency) {
+ return outputError("Missing --currency ");
+ }
+ if (!where) {
+ return outputError("Missing --where ");
+ }
+
+ if (!isValidCurrency(currency)) {
+ return outputError(
+ `Invalid currency: ${currency}. Expected: btc, sats, usdt, usdc`
+ );
+ }
+
+ const resolved = resolveCurrency(currency);
+
+ let amount: number;
+ if (resolved === "btc") {
+ const sats = parseBtcAmount(amountStr, currency);
+ if (sats === null) return outputError(`Invalid amount: ${amountStr}`);
+ amount = sats;
+ } else {
+ amount = parseFloat(amountStr);
+ if (isNaN(amount) || amount <= 0) return outputError(`Invalid amount: ${amountStr}`);
+ }
+
+ if (!isValidWhere(where)) {
+ return outputError(
+ `Invalid where: ${where}. Expected: onchain, lightning, arkade, polygon, arbitrum, ethereum`
+ );
+ }
+
+ const validationError = validateCurrencyWhere(resolved, where);
+ if (validationError) {
+ return outputError(validationError);
+ }
+
+ if (!to) {
+ return outputError("Missing --to ");
+ }
+
+ // Proxy through daemon when running (swap stays in daemon process for monitoring)
+ if (getDaemonUrl()) {
+ const body: Record = { currency: resolved, where, to };
+ if (resolved === "btc") {
+ body.amount = amount;
+ } else {
+ const targetToken = toStablecoinToken(resolved, where);
+ body.targetToken = targetToken;
+ body.targetChain = toEvmChain(where);
+ body.targetAmount = amount;
+ }
+ const result = await daemonPost("/send", body);
+ return outputSuccess(result);
+ }
+
+ // BTC routes (no daemon — direct execution)
+ if (resolved === "btc") {
+ if (where === "lightning") {
+ const result = await ctx.lightning.payInvoice({ bolt11: to });
+ return outputSuccess(result);
+ }
+
+ // arkade or onchain both use BitcoinSkill.send()
+ const result = await ctx.bitcoin.send({ address: to, amount });
+ return outputSuccess(result);
+ }
+
+ // Stablecoin routes (usdt/usdc -> swap BTC to stablecoin)
+ const targetToken = toStablecoinToken(resolved, where);
+ const targetChain = toEvmChain(where);
+
+ const result = await ctx.swap.swapBtcToStablecoin({
+ targetAddress: to,
+ targetToken,
+ targetChain,
+ targetAmount: amount,
+ });
+ return outputSuccess(result);
+}
diff --git a/cli/src/commands/start.ts b/cli/src/commands/start.ts
new file mode 100644
index 0000000..72b2442
--- /dev/null
+++ b/cli/src/commands/start.ts
@@ -0,0 +1,19 @@
+import { getDaemonStatus, getPort, startDaemonInBackground } from "../daemon.js";
+import { outputSuccess, outputError } from "../output.js";
+
+export async function handleStart(): Promise {
+ const status = getDaemonStatus();
+
+ if (status.running) {
+ return outputSuccess({ started: false, reason: "already_running", pid: status.pid, port: status.port });
+ }
+
+ try {
+ const port = getPort();
+ const { pid } = await startDaemonInBackground(port);
+ return outputSuccess({ started: true, pid, port });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return outputError(message);
+ }
+}
diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts
new file mode 100644
index 0000000..eeac8b2
--- /dev/null
+++ b/cli/src/commands/status.ts
@@ -0,0 +1,31 @@
+import { getDaemonStatus } from "../daemon.js";
+import { loadConfig, getSessionStatus } from "../config.js";
+import { outputSuccess } from "../output.js";
+
+export async function handleStatus(): Promise {
+ const config = loadConfig();
+ const session = getSessionStatus(config.sessionToken);
+ const daemon = getDaemonStatus();
+
+ const result: Record = {
+ session: session.active ? "active" : "expired",
+ sessionExpiresAt: session.expiresAt,
+ sessionRemainingSeconds: session.remainingSeconds,
+ daemon: daemon.running
+ ? { running: true, pid: daemon.pid, port: daemon.port }
+ : { running: false },
+ };
+
+ // Fetch detailed daemon status if running
+ if (daemon.running && daemon.port) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${daemon.port}/status`);
+ const data = await res.json();
+ (result.daemon as Record).detail = data;
+ } catch {
+ // Daemon might be starting up or unresponsive
+ }
+ }
+
+ return outputSuccess(result);
+}
diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts
new file mode 100644
index 0000000..c305dcf
--- /dev/null
+++ b/cli/src/commands/stop.ts
@@ -0,0 +1,18 @@
+import { getDaemonStatus, stopDaemon } from "../daemon.js";
+import { outputSuccess, outputError } from "../output.js";
+
+export async function handleStop(): Promise {
+ const status = getDaemonStatus();
+
+ if (!status.running) {
+ return outputSuccess({ stopped: false, reason: "not_running" });
+ }
+
+ try {
+ await stopDaemon();
+ return outputSuccess({ stopped: true, pid: status.pid });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return outputError(message);
+ }
+}
diff --git a/cli/src/commands/swap.ts b/cli/src/commands/swap.ts
new file mode 100644
index 0000000..701143e
--- /dev/null
+++ b/cli/src/commands/swap.ts
@@ -0,0 +1,80 @@
+import type { CashContext } from "../context.js";
+import { outputSuccess, outputError } from "../output.js";
+import { getDaemonUrl, daemonGet } from "../daemonClient.js";
+import type { ParsedArgs } from "minimist";
+
+const LENDASWAP_API = "https://apilendaswap.lendasat.com";
+
+export async function handleSwap(ctx: CashContext, args: ParsedArgs): Promise {
+ const swapId = (args._[1] as string) || (args.id as string);
+
+ if (!swapId) {
+ return outputError("Missing swap ID. Usage: cash swap ");
+ }
+
+ // Proxy through daemon when running
+ if (getDaemonUrl()) {
+ const result = await daemonGet(`/swaps/${encodeURIComponent(swapId)}`);
+ return outputSuccess(result);
+ }
+
+ // Fetch local status and remote API status in parallel
+ const [local, remote] = await Promise.all([
+ ctx.swap.getSwapStatus(swapId).catch(() => null),
+ fetchRemoteSwap(swapId),
+ ]);
+
+ if (!local && !remote) {
+ return outputError(`Swap ${swapId} not found locally or on LendaSat API.`);
+ }
+
+ return outputSuccess(mergeSwapData(swapId, local, remote));
+}
+
+async function fetchRemoteSwap(swapId: string): Promise | null> {
+ try {
+ const res = await fetch(`${LENDASWAP_API}/swap/${encodeURIComponent(swapId)}`);
+ if (!res.ok) return null;
+ return (await res.json()) as Record;
+ } catch {
+ return null;
+ }
+}
+
+function mergeSwapData(
+ swapId: string,
+ local: Awaited> | null,
+ remote: Record | null,
+): Record {
+ const result: Record = { id: swapId };
+
+ if (local) {
+ result.local = {
+ direction: local.direction,
+ status: local.status,
+ sourceToken: local.sourceToken,
+ targetToken: local.targetToken,
+ sourceAmount: local.sourceAmount,
+ targetAmount: local.targetAmount,
+ exchangeRate: local.exchangeRate,
+ createdAt: local.createdAt,
+ completedAt: local.completedAt,
+ txid: local.txid,
+ };
+ }
+
+ if (remote) {
+ result.remote = remote;
+ }
+
+ // Use local status as primary, fall back to remote
+ if (local) {
+ result.status = local.status;
+ result.direction = local.direction;
+ } else if (remote) {
+ result.status = remote.status;
+ result.direction = remote.direction;
+ }
+
+ return result;
+}
diff --git a/cli/src/commands/swaps.ts b/cli/src/commands/swaps.ts
new file mode 100644
index 0000000..a80d294
--- /dev/null
+++ b/cli/src/commands/swaps.ts
@@ -0,0 +1,78 @@
+import { getDaemonStatus } from "../daemon.js";
+import { outputSuccess, outputError } from "../output.js";
+import type { CashContext } from "../context.js";
+import type { ParsedArgs } from "minimist";
+import type { StablecoinSwapInfo, StablecoinSwapStatus } from "@clw-cash/skills";
+
+const CATEGORY_MAP: Record = {
+ pending: "pending",
+ awaiting_funding: "pending",
+ funded: "pending",
+ processing: "pending",
+ completed: "claimed",
+ refunded: "refunded",
+ expired: "expired",
+ failed: "failed",
+};
+
+const ALL_CATEGORIES = ["pending", "claimed", "refunded", "expired", "failed"] as const;
+
+function groupSwaps(
+ swaps: StablecoinSwapInfo[],
+ categories: Set,
+ limit: number
+): Record {
+ const grouped: Record = {};
+ for (const cat of categories) grouped[cat] = [];
+
+ // Sort by createdAt descending
+ const sorted = [...swaps].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ );
+
+ for (const swap of sorted) {
+ const cat = CATEGORY_MAP[swap.status];
+ if (cat && categories.has(cat) && grouped[cat].length < limit) {
+ grouped[cat].push(swap);
+ }
+ }
+
+ return grouped;
+}
+
+export async function handleSwaps(ctx?: CashContext, args?: ParsedArgs): Promise {
+ const limit = typeof args?.limit === "number" ? args.limit : 5;
+
+ // Determine which categories to show
+ const explicit = ALL_CATEGORIES.filter((c) => args?.[c] === true);
+ const categories = new Set(explicit.length > 0 ? explicit : ALL_CATEGORIES);
+
+ // If daemon is running, fetch from it
+ const status = getDaemonStatus();
+ if (status.running && status.port) {
+ try {
+ const params = new URLSearchParams();
+ params.set("limit", String(limit));
+ for (const c of categories) params.append("category", c);
+ const res = await fetch(`http://127.0.0.1:${status.port}/swaps?${params}`);
+ const data = await res.json();
+ return outputSuccess(data);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return outputError(`Failed to fetch swaps from daemon: ${message}`);
+ }
+ }
+
+ // No daemon — query directly if context provided
+ if (!ctx) {
+ return outputError("Daemon is not running. Start it with 'cash start' or provide wallet config.");
+ }
+
+ const [lendaSwaps] = await Promise.all([
+ ctx.swap.getSwapHistory(),
+ ]);
+
+ const lendaswap = groupSwaps(lendaSwaps, categories, limit);
+
+ return outputSuccess({ lendaswap });
+}
diff --git a/cli/src/config.ts b/cli/src/config.ts
new file mode 100644
index 0000000..cc285da
--- /dev/null
+++ b/cli/src/config.ts
@@ -0,0 +1,160 @@
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { homedir } from "node:os";
+
+export interface CashConfig {
+ apiBaseUrl: string;
+ sessionToken: string;
+ identityId: string;
+ publicKey: string;
+ arkServerUrl: string;
+ network: string;
+}
+
+const CONFIG_DIR = join(homedir(), ".clw-cash");
+const CONFIG_FILE = join(CONFIG_DIR, "config.json");
+
+export function loadConfig(overrides?: Partial): CashConfig {
+ let fileConfig: Partial = {};
+
+ try {
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
+ fileConfig = JSON.parse(raw) as Partial;
+ } catch {
+ // No config file, that's fine
+ }
+
+ const config: CashConfig = {
+ apiBaseUrl:
+ overrides?.apiBaseUrl ??
+ process.env.CLW_API_URL ??
+ fileConfig.apiBaseUrl ??
+ "https://api.clw.cash",
+ sessionToken:
+ overrides?.sessionToken ??
+ process.env.CLW_SESSION_TOKEN ??
+ fileConfig.sessionToken ??
+ "",
+ identityId:
+ overrides?.identityId ??
+ process.env.CLW_IDENTITY_ID ??
+ fileConfig.identityId ??
+ "",
+ publicKey:
+ overrides?.publicKey ??
+ process.env.CLW_PUBLIC_KEY ??
+ fileConfig.publicKey ??
+ "",
+ arkServerUrl:
+ overrides?.arkServerUrl ??
+ process.env.CLW_ARK_SERVER_URL ??
+ fileConfig.arkServerUrl ??
+ "https://arkade.computer",
+ network:
+ overrides?.network ??
+ process.env.CLW_NETWORK ??
+ fileConfig.network ??
+ "bitcoin",
+ };
+
+ return config;
+}
+
+export type ConfigSource = "env" | "file" | "default";
+
+export interface ConfigEntry {
+ value: string;
+ source: ConfigSource;
+}
+
+export type CashConfigWithSources = Record;
+
+const ENV_KEYS: Record = {
+ apiBaseUrl: "CLW_API_URL",
+ sessionToken: "CLW_SESSION_TOKEN",
+ identityId: "CLW_IDENTITY_ID",
+ publicKey: "CLW_PUBLIC_KEY",
+ arkServerUrl: "CLW_ARK_SERVER_URL",
+ network: "CLW_NETWORK",
+};
+
+const DEFAULTS: Record = {
+ apiBaseUrl: "https://api.clw.cash",
+ sessionToken: "",
+ identityId: "",
+ publicKey: "",
+ arkServerUrl: "https://arkade.computer",
+ network: "bitcoin",
+};
+
+export function loadConfigWithSources(): CashConfigWithSources {
+ let fileConfig: Partial = {};
+
+ try {
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
+ fileConfig = JSON.parse(raw) as Partial;
+ } catch {
+ // No config file
+ }
+
+ const result = {} as CashConfigWithSources;
+ for (const key of Object.keys(ENV_KEYS) as (keyof CashConfig)[]) {
+ const envVal = process.env[ENV_KEYS[key]];
+ if (envVal !== undefined) {
+ result[key] = { value: envVal, source: "env" };
+ } else if (fileConfig[key]) {
+ result[key] = { value: fileConfig[key], source: "file" };
+ } else {
+ result[key] = { value: DEFAULTS[key], source: "default" };
+ }
+ }
+
+ return result;
+}
+
+export function saveConfig(config: CashConfig): void {
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
+}
+
+export function validateConfig(config: CashConfig): string | null {
+ if (!config.apiBaseUrl) return "Missing apiBaseUrl (set CLW_API_URL)";
+ if (!config.sessionToken) return "Missing sessionToken (set CLW_SESSION_TOKEN)";
+ if (!config.identityId) return "Missing identityId (set CLW_IDENTITY_ID)";
+ if (!config.publicKey) return "Missing publicKey (set CLW_PUBLIC_KEY)";
+ if (!config.arkServerUrl) return "Missing arkServerUrl (set CLW_ARK_SERVER_URL)";
+ return null;
+}
+
+export interface SessionStatus {
+ active: boolean;
+ expiresAt: string | null;
+ remainingSeconds: number | null;
+}
+
+/** Decode JWT payload without verification to check expiry locally */
+export function getSessionStatus(token: string): SessionStatus {
+ if (!token) return { active: false, expiresAt: null, remainingSeconds: null };
+
+ try {
+ const parts = token.split(".");
+ if (parts.length !== 3) return { active: false, expiresAt: null, remainingSeconds: null };
+
+ const payload = JSON.parse(
+ Buffer.from(parts[1], "base64url").toString("utf-8")
+ ) as { exp?: number };
+
+ if (!payload.exp) return { active: true, expiresAt: null, remainingSeconds: null };
+
+ const expiresAt = new Date(payload.exp * 1000);
+ const remainingSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
+
+ return {
+ active: remainingSeconds > 0,
+ expiresAt: expiresAt.toISOString(),
+ remainingSeconds: Math.max(0, remainingSeconds),
+ };
+ } catch {
+ return { active: false, expiresAt: null, remainingSeconds: null };
+ }
+}
diff --git a/cli/src/context.ts b/cli/src/context.ts
new file mode 100644
index 0000000..1f54be0
--- /dev/null
+++ b/cli/src/context.ts
@@ -0,0 +1,79 @@
+import { join } from "node:path";
+import { homedir } from "node:os";
+import { mkdirSync } from "node:fs";
+import { RemoteSignerIdentity } from "@clw-cash/sdk";
+import { Wallet } from "@arkade-os/sdk";
+import { FileSystemStorageAdapter } from "@arkade-os/sdk/adapters/fileSystem";
+import {
+ SqliteWalletStorage,
+ SqliteSwapStorage,
+} from "@lendasat/lendaswap-sdk-pure/node";
+import {
+ ArkadeBitcoinSkill,
+ ArkadeLightningSkill,
+ LendaSwapSkill,
+} from "@clw-cash/skills";
+import type { CashConfig } from "./config.js";
+import type { NetworkName } from "@arkade-os/sdk";
+
+const DATA_DIR = join(homedir(), ".clw-cash", "data");
+const LENDASWAP_DB = join(homedir(), ".clw-cash", "lendaswap.db");
+
+export interface CashContext {
+ identity: RemoteSignerIdentity;
+ bitcoin: ArkadeBitcoinSkill;
+ lightning: ArkadeLightningSkill;
+ swap: LendaSwapSkill;
+ dispose(): Promise;
+}
+
+export interface CreateContextOpts {
+ enableSwapManager?: boolean;
+}
+
+export async function createContext(config: CashConfig, opts?: CreateContextOpts): Promise {
+ const identity = new RemoteSignerIdentity({
+ apiBaseUrl: config.apiBaseUrl,
+ identityId: config.identityId,
+ sessionToken: config.sessionToken,
+ compressedPublicKey: config.publicKey,
+ });
+
+ // Persistent filesystem storage for Wallet + Boltz swap (contractRepository)
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
+ const walletStorage = new FileSystemStorageAdapter(DATA_DIR);
+
+ const wallet = await Wallet.create({
+ identity,
+ arkServerUrl: config.arkServerUrl,
+ storage: walletStorage,
+ });
+
+ const bitcoin = new ArkadeBitcoinSkill(wallet);
+
+ const lightning = new ArkadeLightningSkill({
+ wallet,
+ network: config.network as NetworkName,
+ enableSwapManager: opts?.enableSwapManager,
+ });
+
+ // Persistent SQLite storage for LendaSwap
+ const lendaWalletStorage = new SqliteWalletStorage(LENDASWAP_DB);
+ const lendaSwapStorage = new SqliteSwapStorage(LENDASWAP_DB);
+
+ const swap = new LendaSwapSkill({
+ wallet,
+ walletStorage: lendaWalletStorage,
+ swapStorage: lendaSwapStorage,
+ });
+
+ return {
+ identity,
+ bitcoin,
+ lightning,
+ swap,
+ async dispose() {
+ await lightning.dispose();
+ },
+ };
+}
diff --git a/cli/src/daemon.ts b/cli/src/daemon.ts
new file mode 100644
index 0000000..0ef4cc6
--- /dev/null
+++ b/cli/src/daemon.ts
@@ -0,0 +1,137 @@
+import { spawn } from "node:child_process";
+import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync } from "node:fs";
+import { join } from "node:path";
+import { homedir } from "node:os";
+
+const CONFIG_DIR = join(homedir(), ".clw-cash");
+const PID_FILE = join(CONFIG_DIR, "daemon.pid");
+const LOG_FILE = join(CONFIG_DIR, "daemon.log");
+
+interface PidFileData {
+ pid: number;
+ port: number;
+}
+
+export interface DaemonStatus {
+ running: boolean;
+ pid?: number;
+ port?: number;
+}
+
+export function getPort(): number {
+ const envPort = process.env.CLW_DAEMON_PORT;
+ if (envPort) {
+ const parsed = parseInt(envPort, 10);
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
+ }
+ return 3457;
+}
+
+export function getDaemonStatus(): DaemonStatus {
+ let data: PidFileData;
+ try {
+ const raw = readFileSync(PID_FILE, "utf-8");
+ data = JSON.parse(raw) as PidFileData;
+ } catch {
+ return { running: false };
+ }
+
+ try {
+ process.kill(data.pid, 0);
+ return { running: true, pid: data.pid, port: data.port };
+ } catch {
+ // Stale PID file — process no longer exists
+ try { unlinkSync(PID_FILE); } catch { /* ignore */ }
+ return { running: false };
+ }
+}
+
+export function saveDaemonPid(pid: number, port: number): void {
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
+ writeFileSync(PID_FILE, JSON.stringify({ pid, port }), { mode: 0o600 });
+}
+
+export function removeDaemonPid(): void {
+ try { unlinkSync(PID_FILE); } catch { /* ignore */ }
+}
+
+export async function startDaemonInBackground(port: number): Promise<{ pid: number }> {
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
+
+ const logFd = openSync(LOG_FILE, "a");
+
+ // Use the same node binary and entrypoint that's currently running
+ // This works both in dev (tsx cli/src/index.ts) and production (node dist/index.js)
+ const nodeBin = process.execPath;
+ const entrypoint = process.argv[1];
+
+ const child = spawn(nodeBin, [entrypoint, "--daemon-internal", "--port", String(port)], {
+ cwd: process.cwd(),
+ env: { ...process.env },
+ detached: true,
+ stdio: ["ignore", logFd, logFd],
+ });
+
+ const pid = child.pid;
+ if (!pid) {
+ throw new Error("Failed to spawn daemon process");
+ }
+
+ child.unref();
+
+ // Poll /health until ready
+ const deadline = Date.now() + 30_000;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
+ if (res.ok) {
+ return { pid };
+ }
+ } catch {
+ // Not ready yet
+ }
+ await new Promise((r) => setTimeout(r, 250));
+ }
+
+ // Timed out — kill the process
+ try { process.kill(pid, "SIGTERM"); } catch { /* ignore */ }
+ throw new Error(`Daemon did not become healthy within 30s (pid: ${pid})`);
+}
+
+export async function ensureDaemonRunning(): Promise<{ pid: number; port: number }> {
+ const status = getDaemonStatus();
+ if (status.running && status.pid && status.port) {
+ return { pid: status.pid, port: status.port };
+ }
+
+ const port = getPort();
+ const { pid } = await startDaemonInBackground(port);
+ return { pid, port };
+}
+
+export async function stopDaemon(): Promise {
+ const status = getDaemonStatus();
+ if (!status.running || !status.pid) {
+ return false;
+ }
+
+ process.kill(status.pid, "SIGTERM");
+
+ // Wait for process to exit (up to 5s)
+ const deadline = Date.now() + 5_000;
+ while (Date.now() < deadline) {
+ try {
+ process.kill(status.pid, 0);
+ } catch {
+ // Process exited
+ removeDaemonPid();
+ return true;
+ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+
+ // Force kill
+ try { process.kill(status.pid, "SIGKILL"); } catch { /* ignore */ }
+ removeDaemonPid();
+ return true;
+}
diff --git a/cli/src/daemonClient.ts b/cli/src/daemonClient.ts
new file mode 100644
index 0000000..5b625d7
--- /dev/null
+++ b/cli/src/daemonClient.ts
@@ -0,0 +1,36 @@
+import { getDaemonStatus } from "./daemon.js";
+
+export function getDaemonUrl(): string | null {
+ const status = getDaemonStatus();
+ if (!status.running || !status.port) return null;
+ return `http://127.0.0.1:${status.port}`;
+}
+
+export async function daemonPost(path: string, body: unknown): Promise {
+ const url = getDaemonUrl();
+ if (!url) throw new Error("Daemon is not running");
+
+ const res = await fetch(`${url}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error((data as { error?: string }).error ?? `Daemon error: ${res.status}`);
+ }
+ return data;
+}
+
+export async function daemonGet(path: string): Promise {
+ const url = getDaemonUrl();
+ if (!url) throw new Error("Daemon is not running");
+
+ const res = await fetch(`${url}${path}`);
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error((data as { error?: string }).error ?? `Daemon error: ${res.status}`);
+ }
+ return data;
+}
diff --git a/cli/src/index.ts b/cli/src/index.ts
new file mode 100644
index 0000000..eed55ed
--- /dev/null
+++ b/cli/src/index.ts
@@ -0,0 +1,321 @@
+import minimist from "minimist";
+import { loadConfig, validateConfig, getSessionStatus, saveConfig } from "./config.js";
+import { createContext } from "./context.js";
+import { outputError } from "./output.js";
+import { handleSend } from "./commands/send.js";
+import { handleReceive } from "./commands/receive.js";
+import { handleBalance } from "./commands/balance.js";
+import { handleInit } from "./commands/init.js";
+import { handleStart } from "./commands/start.js";
+import { handleStop } from "./commands/stop.js";
+import { handleStatus } from "./commands/status.js";
+import { handleSwaps } from "./commands/swaps.js";
+import { handleClaim } from "./commands/claim.js";
+import { handleRefund } from "./commands/refund.js";
+import { handleLogin } from "./commands/login.js";
+import { handleSwap } from "./commands/swap.js";
+import { handleConfig } from "./commands/config.js";
+import { handleFetch } from "./commands/fetch.js";
+
+const HELP = `cash - Bitcoin & Stablecoin CLI
+
+Usage:
+ cash send --amount --currency --where --to
+ cash send
+ cash send
+ cash receive --amount --currency --where
+ cash balance
+ cash init --api-url --token --ark-server
+ cash login Re-authenticate via Telegram (refresh token)
+ cash skill Print SKILL.md (agent instructions)
+ cash config Show resolved configuration and sources
+ cash start Start background daemon (swap monitoring)
+ cash stop Stop background daemon
+ cash status Show daemon status
+ cash swap Check swap status (local + LendaSat API)
+ cash swaps List swaps (last 5 per category)
+ --pending --claimed --refunded --expired --failed (filter)
+ --limit Max per category (default: 5)
+ cash claim Manually claim a swap (reveal preimage)
+ cash refund Manually refund a swap
+ --address Refund destination (optional)
+ cash fetch Fetch URL with x402 payment support
+ --method HTTP method (default: GET)
+ --body Request body (for POST)
+ --header Custom header (repeatable)
+
+Currency: btc | sats | usdt | usdc
+ btc = amount in BTC (e.g. 0.001), sats = amount in satoshis (e.g. 100000)
+Where: onchain | lightning | arkade | polygon | arbitrum | ethereum
+
+Examples:
+ cash send --amount 0.001 --currency btc --where arkade --to ark1q...
+ cash send --amount 100000 --currency sats --where arkade --to ark1q...
+ cash send --amount 50000 --currency sats --where lightning --to lnbc1...
+ cash send lnbc500n1...
+ cash receive --amount 100000 --currency sats --where lightning
+ cash receive --amount 10 --currency usdt --where polygon --address 0x...
+ cash balance
+ cash start
+ cash status
+
+Environment:
+ CLW_API_URL API base URL
+ CLW_SESSION_TOKEN JWT session token
+ CLW_IDENTITY_ID Identity UUID
+ CLW_PUBLIC_KEY Compressed public key (hex)
+ CLW_ARK_SERVER_URL Arkade server URL
+ CLW_NETWORK Network (bitcoin|testnet)
+ CLW_DAEMON_PORT Daemon port (default: 3457)
+`;
+
+const argv = minimist(process.argv.slice(2), {
+ string: [
+ "amount", "currency", "where", "to", "address", "id",
+ "api-url", "token", "ark-server", "network", "port",
+ "bot-token", "chat-id", "message-id",
+ "method", "body", "header",
+ ],
+ boolean: ["help", "version", "daemon-internal", "start"],
+ alias: { h: "help", v: "version" },
+});
+
+const command = argv._[0] as string | undefined;
+
+// --daemon-internal: run as the daemon process (spawned by `cash start`)
+if (argv["daemon-internal"]) {
+ runDaemon();
+} else {
+ if (argv.help || !command) {
+ console.log(HELP);
+ process.exit(0);
+ }
+
+ if (argv.version) {
+ console.log("0.1.0");
+ process.exit(0);
+ }
+
+ main();
+}
+
+async function main() {
+ try {
+ // Commands that don't need a full context
+ switch (command) {
+ case "init":
+ await handleInit(argv);
+ return;
+ case "login":
+ await handleLogin(argv);
+ return;
+ case "skill": {
+ const { readFileSync } = await import("node:fs");
+ const { join, dirname } = await import("node:path");
+ const { fileURLToPath } = await import("node:url");
+ const __dirname = dirname(fileURLToPath(import.meta.url));
+ const skillPath = join(__dirname, "..", "SKILL.md");
+ process.stdout.write(readFileSync(skillPath, "utf-8"));
+ return;
+ }
+ case "config":
+ await handleConfig();
+ return;
+ case "start":
+ await handleStart();
+ return;
+ case "stop":
+ await handleStop();
+ return;
+ case "status":
+ await handleStatus();
+ return;
+ }
+
+ const config = loadConfig();
+ const configError = validateConfig(config);
+ if (configError) {
+ outputError(configError);
+ }
+
+ // Check session expiry and auto re-login if expired
+ const session = getSessionStatus(config.sessionToken);
+ if (!session.active) {
+ console.error("Session expired. Re-authenticating...");
+ const freshToken = await refreshSession(config);
+ config.sessionToken = freshToken;
+ saveConfig(config);
+ console.error("Session refreshed.");
+ }
+
+ const ctx = await createContext(config);
+
+ switch (command) {
+ case "send":
+ await handleSend(ctx, argv);
+ break;
+ case "receive":
+ await handleReceive(ctx, argv, config);
+ break;
+ case "balance":
+ await handleBalance(ctx);
+ break;
+ case "swap":
+ await handleSwap(ctx, argv);
+ break;
+ case "swaps":
+ await handleSwaps(ctx, argv);
+ break;
+ case "claim":
+ await handleClaim(ctx, argv);
+ break;
+ case "refund":
+ await handleRefund(ctx, argv);
+ break;
+ case "fetch":
+ await handleFetch(ctx, argv);
+ break;
+ default:
+ outputError(`Unknown command: ${command}. Run 'cash --help' for usage.`);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ outputError(message);
+ }
+}
+
+async function refreshSession(config: import("./config.js").CashConfig): Promise {
+ const { getDaemonStatus, stopDaemon, startDaemonInBackground, getPort } = await import("./daemon.js");
+
+ // Request a challenge (auto-resolves in test mode)
+ const challengeRes = await fetch(`${config.apiBaseUrl}/v1/auth/challenge`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ telegram_user_id: "test_user" }),
+ });
+ if (!challengeRes.ok) {
+ throw new Error(`Auth challenge failed: ${await challengeRes.text()}`);
+ }
+
+ const challenge = (await challengeRes.json()) as {
+ challenge_id: string;
+ deep_link: string | null;
+ };
+
+ if (challenge.deep_link) {
+ console.error(`Open this link to authenticate:\n\n ${challenge.deep_link}\n`);
+ console.error("Waiting for confirmation...");
+ }
+
+ // Poll verify (120s timeout)
+ const deadline = Date.now() + 120_000;
+ while (Date.now() < deadline) {
+ const verifyRes = await fetch(`${config.apiBaseUrl}/v1/auth/verify`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ challenge_id: challenge.challenge_id }),
+ });
+
+ if (verifyRes.ok) {
+ const session = (await verifyRes.json()) as { token: string };
+ const token = session.token;
+
+ // Restore identity with fresh token
+ if (config.identityId && config.publicKey) {
+ const restoreRes = await fetch(
+ `${config.apiBaseUrl}/v1/identities/${config.identityId}/restore`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ public_key: config.publicKey }),
+ }
+ );
+ if (!restoreRes.ok) {
+ console.error(`Warning: identity restore failed: ${await restoreRes.text()}`);
+ }
+ }
+
+ // Restart daemon if running (so it picks up the new token)
+ const daemonStatus = getDaemonStatus();
+ if (daemonStatus.running) {
+ console.error("Restarting daemon with new session...");
+ await stopDaemon();
+ try {
+ const port = getPort();
+ await startDaemonInBackground(port);
+ } catch (err) {
+ console.error(`Warning: daemon restart failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+
+ return token;
+ }
+
+ if (verifyRes.status === 202) {
+ await new Promise((r) => setTimeout(r, 2000));
+ continue;
+ }
+
+ throw new Error(`Auth verify failed: ${await verifyRes.text()}`);
+ }
+
+ throw new Error("Re-authentication timed out");
+}
+
+async function runDaemon() {
+ const { saveDaemonPid, removeDaemonPid } = await import("./daemon.js");
+ const { SwapMonitor } = await import("./monitor.js");
+ const { AuthMonitor } = await import("./authMonitor.js");
+ const { createDaemonServer } = await import("./server.js");
+
+ const port = argv.port ? parseInt(argv.port as string, 10) : 3457;
+
+ const config = loadConfig();
+ const configError = validateConfig(config);
+ if (configError) {
+ console.error(`[daemon] config error: ${configError}`);
+ process.exit(1);
+ }
+
+ console.error(`[daemon] starting on port ${port}...`);
+
+ const ctx = await createContext(config, { enableSwapManager: true });
+ const { WebhookRegistry } = await import("./notifier.js");
+ const webhookRegistry = new WebhookRegistry();
+ const monitor = new SwapMonitor(ctx, { onEvent: (e) => webhookRegistry.dispatch(e) });
+ const authMonitor = new AuthMonitor();
+ const server = createDaemonServer({ port, ctx, monitor, authMonitor, webhookRegistry });
+
+ // Start Lightning SwapManager
+ await ctx.lightning.startSwapManager();
+ console.error("[daemon] lightning swap manager started");
+
+ // Start LendaSwap poller
+ monitor.start();
+ console.error("[daemon] lendaswap monitor started");
+
+ // Start HTTP server
+ server.listen(port, "127.0.0.1", () => {
+ saveDaemonPid(process.pid, port);
+ console.error(`[daemon] listening on http://127.0.0.1:${port}`);
+ });
+
+ // Graceful shutdown
+ const shutdown = async () => {
+ console.error("[daemon] shutting down...");
+ monitor.stop();
+ authMonitor.stop();
+ await ctx.lightning.stopSwapManager();
+ await ctx.dispose();
+ server.close();
+ removeDaemonPid();
+ console.error("[daemon] stopped");
+ process.exit(0);
+ };
+
+ process.on("SIGTERM", () => void shutdown());
+ process.on("SIGINT", () => void shutdown());
+}
diff --git a/cli/src/monitor.ts b/cli/src/monitor.ts
new file mode 100644
index 0000000..a059daf
--- /dev/null
+++ b/cli/src/monitor.ts
@@ -0,0 +1,126 @@
+import type { CashContext } from "./context.js";
+import type { SwapEvent } from "./notifier.js";
+
+export interface SwapMonitorOpts {
+ pollIntervalMs?: number;
+ onEvent?: (event: SwapEvent) => void;
+}
+
+export class SwapMonitor {
+ private readonly ctx: CashContext;
+ private readonly pollIntervalMs: number;
+ private readonly onEvent?: (event: SwapEvent) => void;
+ private timer: ReturnType | null = null;
+ private lastPollTime: Date | null = null;
+ private polling = false;
+
+ constructor(ctx: CashContext, opts?: SwapMonitorOpts) {
+ this.ctx = ctx;
+ this.pollIntervalMs = opts?.pollIntervalMs ?? 30_000;
+ this.onEvent = opts?.onEvent;
+ }
+
+ start(): void {
+ if (this.timer) return;
+ this.timer = setInterval(() => void this.poll(), this.pollIntervalMs);
+ // Run immediately on start
+ void this.poll();
+ }
+
+ stop(): void {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ }
+
+ getLastPollTime(): Date | null {
+ return this.lastPollTime;
+ }
+
+ isRunning(): boolean {
+ return this.timer !== null;
+ }
+
+ private emit(event: SwapEvent): void {
+ try {
+ this.onEvent?.(event);
+ } catch (err) {
+ console.error(`[monitor] onEvent error: ${err}`);
+ }
+ }
+
+ private async poll(): Promise {
+ if (this.polling) return; // Skip if previous poll still running
+ this.polling = true;
+
+ try {
+ const pending = await this.ctx.swap.getPendingSwaps();
+ this.lastPollTime = new Date();
+
+ for (const swap of pending) {
+ const base = {
+ swapId: swap.id,
+ status: swap.status,
+ direction: swap.direction,
+ sourceAmount: swap.sourceAmount,
+ sourceToken: swap.sourceToken,
+ targetAmount: swap.targetAmount,
+ targetToken: swap.targetToken,
+ timestamp: new Date().toISOString(),
+ };
+
+ // Attempt claim for swaps in "processing" status (server has funded)
+ if (swap.status === "processing") {
+ try {
+ const result = await this.ctx.swap.claimSwap(swap.id);
+ const message = result.success
+ ? `Swap ${swap.id.slice(0, 8)}… claimed: ${swap.sourceAmount} ${swap.sourceToken} → ${swap.targetAmount} ${swap.targetToken}`
+ : `Swap ${swap.id.slice(0, 8)}… claim returned: ${result.message}`;
+ console.error(`[monitor] ${message}`);
+ if (result.success) {
+ this.emit({ ...base, event: "swap.claimed", status: "completed", message });
+ } else {
+ this.emit({ ...base, event: "swap.failed", message, error: result.message });
+ }
+ } catch (err) {
+ const errMsg = err instanceof Error ? err.message : String(err);
+ const message = `Swap ${swap.id.slice(0, 8)}… claim failed: ${errMsg}`;
+ console.error(`[monitor] ${message}`);
+ this.emit({ ...base, event: "swap.failed", message, error: errMsg });
+ }
+ }
+
+ // Attempt refund for expired swaps
+ if (swap.status === "expired") {
+ try {
+ const result = await this.ctx.swap.refundSwap(swap.id);
+ const message = result.success
+ ? `Swap ${swap.id.slice(0, 8)}… refunded: ${swap.sourceAmount} ${swap.sourceToken} returned`
+ : `Swap ${swap.id.slice(0, 8)}… refund returned: ${result.message}`;
+ console.error(`[monitor] ${message}`);
+ if (result.success) {
+ this.emit({ ...base, event: "swap.refunded", status: "refunded", message });
+ } else {
+ this.emit({ ...base, event: "swap.failed", message, error: result.message });
+ }
+ } catch (err) {
+ const errMsg = err instanceof Error ? err.message : String(err);
+ const message = `Swap ${swap.id.slice(0, 8)}… refund failed: ${errMsg}`;
+ console.error(`[monitor] ${message}`);
+ this.emit({ ...base, event: "swap.failed", message, error: errMsg });
+ }
+ }
+ }
+
+ if (pending.length > 0) {
+ console.error(`[monitor] polled ${pending.length} pending swap(s)`);
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`[monitor] poll error: ${msg}`);
+ } finally {
+ this.polling = false;
+ }
+ }
+}
diff --git a/cli/src/notifier.ts b/cli/src/notifier.ts
new file mode 100644
index 0000000..fb8753e
--- /dev/null
+++ b/cli/src/notifier.ts
@@ -0,0 +1,62 @@
+import { randomUUID } from "node:crypto";
+
+export type SwapEventType = "swap.claimed" | "swap.refunded" | "swap.failed";
+
+export interface SwapEvent {
+ event: SwapEventType;
+ swapId: string;
+ status: string;
+ direction: string;
+ sourceAmount: number;
+ sourceToken: string;
+ targetAmount: number;
+ targetToken: string;
+ message: string;
+ error?: string;
+ timestamp: string;
+}
+
+interface WebhookRegistration {
+ id: string;
+ url: string;
+ events: SwapEventType[];
+}
+
+const VALID_EVENTS = new Set(["swap.claimed", "swap.refunded", "swap.failed"]);
+
+export class WebhookRegistry {
+ private readonly webhooks = new Map();
+
+ register(url: string, events: SwapEventType[]): WebhookRegistration {
+ const filtered = events.filter((e) => VALID_EVENTS.has(e));
+ if (filtered.length === 0) {
+ throw new Error(`No valid events. Valid: ${[...VALID_EVENTS].join(", ")}`);
+ }
+
+ const reg: WebhookRegistration = { id: randomUUID(), url, events: filtered };
+ this.webhooks.set(reg.id, reg);
+ return reg;
+ }
+
+ unregister(id: string): boolean {
+ return this.webhooks.delete(id);
+ }
+
+ list(): WebhookRegistration[] {
+ return [...this.webhooks.values()];
+ }
+
+ dispatch(event: SwapEvent): void {
+ for (const reg of this.webhooks.values()) {
+ if (!reg.events.includes(event.event)) continue;
+
+ fetch(reg.url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(event),
+ }).catch((err) => {
+ console.error(`[notifier] webhook ${reg.id} (${reg.url}) failed: ${err}`);
+ });
+ }
+ }
+}
diff --git a/cli/src/output.ts b/cli/src/output.ts
new file mode 100644
index 0000000..91ae6d2
--- /dev/null
+++ b/cli/src/output.ts
@@ -0,0 +1,9 @@
+export function outputSuccess(data: unknown): never {
+ console.log(JSON.stringify({ ok: true, data }, null, 2));
+ process.exit(0);
+}
+
+export function outputError(error: string, details?: unknown): never {
+ console.error(JSON.stringify({ ok: false, error, details }, null, 2));
+ process.exit(1);
+}
diff --git a/cli/src/server.ts b/cli/src/server.ts
new file mode 100644
index 0000000..3cb641d
--- /dev/null
+++ b/cli/src/server.ts
@@ -0,0 +1,368 @@
+import { createServer, type Server, type IncomingMessage } from "node:http";
+import type { CashContext } from "./context.js";
+import type { SwapMonitor } from "./monitor.js";
+import type { AuthMonitor } from "./authMonitor.js";
+import type { WebhookRegistry, SwapEventType } from "./notifier.js";
+import type { EvmChain, StablecoinToken, StablecoinSwapInfo, StablecoinSwapStatus } from "@clw-cash/skills";
+
+const LENDASWAP_API = "https://apilendaswap.lendasat.com";
+
+async function fetchRemoteSwap(swapId: string): Promise | null> {
+ try {
+ const res = await fetch(`${LENDASWAP_API}/swap/${encodeURIComponent(swapId)}`);
+ if (!res.ok) return null;
+ return (await res.json()) as Record;
+ } catch {
+ return null;
+ }
+}
+
+const CATEGORY_MAP: Record = {
+ pending: "pending",
+ awaiting_funding: "pending",
+ funded: "pending",
+ processing: "pending",
+ completed: "claimed",
+ refunded: "refunded",
+ expired: "expired",
+ failed: "failed",
+};
+
+function groupSwaps(
+ swaps: StablecoinSwapInfo[],
+ categories: Set,
+ limit: number
+): Record {
+ const grouped: Record = {};
+ for (const cat of categories) grouped[cat] = [];
+
+ const sorted = [...swaps].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ );
+
+ for (const swap of sorted) {
+ const cat = CATEGORY_MAP[swap.status];
+ if (cat && categories.has(cat) && grouped[cat].length < limit) {
+ grouped[cat].push(swap);
+ }
+ }
+
+ return grouped;
+}
+
+export interface DaemonServerOpts {
+ port: number;
+ ctx: CashContext;
+ monitor: SwapMonitor;
+ authMonitor: AuthMonitor;
+ webhookRegistry: WebhookRegistry;
+}
+
+async function readBody(req: IncomingMessage): Promise> {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (c: Buffer) => chunks.push(c));
+ req.on("end", () => {
+ try {
+ const text = Buffer.concat(chunks).toString();
+ resolve(text ? JSON.parse(text) : {});
+ } catch (err) { reject(err); }
+ });
+ req.on("error", reject);
+ });
+}
+
+export function createDaemonServer(opts: DaemonServerOpts): Server {
+ const { ctx, monitor, authMonitor, webhookRegistry } = opts;
+ const startTime = Date.now();
+
+ const server = createServer(async (req, res) => {
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
+ const method = req.method ?? "GET";
+
+ res.setHeader("content-type", "application/json");
+
+ try {
+ // GET /health
+ if (method === "GET" && url.pathname === "/health") {
+ return json(res, 200, {
+ ok: true,
+ uptime: Math.floor((Date.now() - startTime) / 1000),
+ swapManager: true,
+ lendaPoller: monitor.isRunning(),
+ });
+ }
+
+ // GET /status
+ if (method === "GET" && url.pathname === "/status") {
+ const [lightningPending, lendaPending] = await Promise.all([
+ ctx.lightning.getPendingSwaps(),
+ ctx.swap.getPendingSwaps(),
+ ]);
+
+ return json(res, 200, {
+ lightning: { pending: lightningPending.length },
+ lendaswap: {
+ pending: lendaPending.length,
+ lastPoll: monitor.getLastPollTime()?.toISOString() ?? null,
+ },
+ });
+ }
+
+ // GET /swaps
+ if (method === "GET" && url.pathname === "/swaps") {
+ const limit = parseInt(url.searchParams.get("limit") ?? "5", 10) || 5;
+ const reqCategories = url.searchParams.getAll("category");
+ const allCats = ["pending", "claimed", "refunded", "expired", "failed"];
+ const categories = new Set(reqCategories.length > 0 ? reqCategories : allCats);
+
+ const lendaSwaps = await ctx.swap.getSwapHistory();
+ const lendaswap = groupSwaps(lendaSwaps, categories, limit);
+
+ return json(res, 200, { lendaswap });
+ }
+
+ // GET /swaps/:id — single swap status (local + remote)
+ if (method === "GET" && url.pathname.startsWith("/swaps/") && url.pathname.split("/").length === 3) {
+ const swapId = url.pathname.split("/")[2];
+ if (!swapId) {
+ return json(res, 400, { error: "Missing swap ID" });
+ }
+
+ const [local, remote] = await Promise.all([
+ ctx.swap.getSwapStatus(swapId).catch(() => null),
+ fetchRemoteSwap(swapId),
+ ]);
+
+ if (!local && !remote) {
+ return json(res, 404, { error: `Swap ${swapId} not found` });
+ }
+
+ const result: Record = { id: swapId };
+ if (local) {
+ result.local = local;
+ result.status = local.status;
+ result.direction = local.direction;
+ }
+ if (remote) {
+ result.remote = remote;
+ if (!local) {
+ result.status = remote.status;
+ result.direction = remote.direction;
+ }
+ }
+
+ return json(res, 200, result);
+ }
+
+ // POST /swaps/:id/claim
+ if (method === "POST" && url.pathname.startsWith("/swaps/") && url.pathname.endsWith("/claim")) {
+ const parts = url.pathname.split("/");
+ const swapId = parts[2];
+ if (!swapId) {
+ return json(res, 400, { error: "Missing swap ID" });
+ }
+
+ const result = await ctx.swap.claimSwap(swapId);
+ return json(res, 200, result);
+ }
+
+ // POST /swaps/:id/refund
+ if (method === "POST" && url.pathname.startsWith("/swaps/") && url.pathname.endsWith("/refund")) {
+ const parts = url.pathname.split("/");
+ const swapId = parts[2];
+ if (!swapId) {
+ return json(res, 400, { error: "Missing swap ID" });
+ }
+
+ const body = await readBody(req);
+ const destinationAddress = body.destinationAddress as string | undefined;
+
+ const info = await ctx.swap.getSwapStatus(swapId);
+
+ if (info.direction === "stablecoin_to_btc") {
+ const callData = await ctx.swap.getEvmRefundCallData(swapId);
+ return json(res, 200, {
+ type: "evm_refund",
+ swapId,
+ timelockExpired: callData.timelockExpired,
+ timelockExpiry: callData.timelockExpiry,
+ transaction: { to: callData.to, data: callData.data },
+ });
+ }
+
+ const result = await ctx.swap.refundSwap(swapId, { destinationAddress });
+ return json(res, 200, result);
+ }
+
+ // POST /receive — create invoice/address via daemon (swap stays in-process for monitoring)
+ if (method === "POST" && url.pathname === "/receive") {
+ const body = await readBody(req);
+ const amount = body.amount as number;
+ const currency = body.currency as string;
+ const where = body.where as string;
+
+ if (currency === "btc" && where === "lightning") {
+ const invoice = await ctx.lightning.createInvoice({ amount });
+ return json(res, 200, invoice);
+ }
+
+ if (currency === "btc" && where === "arkade") {
+ const address = await ctx.bitcoin.getArkAddress();
+ return json(res, 200, { address, type: "ark", amount });
+ }
+
+ if (currency === "btc" && where === "onchain") {
+ const address = await ctx.bitcoin.getBoardingAddress();
+ return json(res, 200, { address, type: "onchain", amount });
+ }
+
+ // Stablecoin receive (swap stablecoin -> BTC)
+ const arkAddress = body.targetAddress as string || await ctx.bitcoin.getArkAddress();
+ const result = await ctx.swap.swapStablecoinToBtc({
+ sourceChain: body.sourceChain as EvmChain,
+ sourceToken: body.sourceToken as StablecoinToken,
+ sourceAmount: (amount as number) || 0,
+ targetAddress: arkAddress,
+ userAddress: (body.userAddress as string) || undefined,
+ });
+ return json(res, 200, result);
+ }
+
+ // POST /send — send/pay via daemon (swap stays in-process for monitoring)
+ if (method === "POST" && url.pathname === "/send") {
+ const body = await readBody(req);
+ const amount = body.amount as number;
+ const currency = body.currency as string;
+ const where = body.where as string;
+ const to = body.to as string;
+
+ if (currency === "btc" && where === "lightning") {
+ const result = await ctx.lightning.payInvoice({ bolt11: to });
+ return json(res, 200, result);
+ }
+
+ if (currency === "btc") {
+ const result = await ctx.bitcoin.send({ address: to, amount });
+ return json(res, 200, result);
+ }
+
+ // Stablecoin send (swap BTC -> stablecoin)
+ const result = await ctx.swap.swapBtcToStablecoin({
+ targetAddress: to,
+ targetToken: body.targetToken as StablecoinToken,
+ targetChain: body.targetChain as EvmChain,
+ targetAmount: body.targetAmount as number,
+ });
+ return json(res, 200, result);
+ }
+
+ // GET /balance
+ if (method === "GET" && url.pathname === "/balance") {
+ const balance = await ctx.bitcoin.getBalance();
+ return json(res, 200, balance);
+ }
+
+ // POST /auth/start — begin non-blocking auth, daemon polls in background
+ if (method === "POST" && url.pathname === "/auth/start") {
+ const body = await readBody(req);
+ const botToken = body.botToken as string;
+ const chatId = body.chatId as number;
+ const messageId = body.messageId as number;
+
+ if (!botToken || !chatId || !messageId) {
+ return json(res, 400, { error: "Missing botToken, chatId, or messageId" });
+ }
+
+ const { loadConfig } = await import("./config.js");
+ const config = loadConfig();
+
+ // Request a challenge from the API
+ const challengeRes = await fetch(`${config.apiBaseUrl}/v1/auth/challenge`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ if (!challengeRes.ok) {
+ const text = await challengeRes.text();
+ return json(res, 502, { error: `Challenge request failed: ${text}` });
+ }
+
+ const challenge = (await challengeRes.json()) as {
+ challenge_id: string;
+ deep_link: string | null;
+ };
+
+ // Start background polling
+ authMonitor.watch({
+ challengeId: challenge.challenge_id,
+ deepLink: challenge.deep_link,
+ apiBaseUrl: config.apiBaseUrl,
+ botToken,
+ chatId,
+ messageId,
+ createdAt: Date.now(),
+ });
+
+ return json(res, 200, {
+ challengeId: challenge.challenge_id,
+ deepLink: challenge.deep_link,
+ });
+ }
+
+ // GET /auth/status — check if auth is still polling or resolved
+ if (method === "GET" && url.pathname === "/auth/status") {
+ return json(res, 200, authMonitor.getStatus());
+ }
+
+ // POST /notify/register — register a webhook for swap events
+ if (method === "POST" && url.pathname === "/notify/register") {
+ const body = await readBody(req);
+ const webhookUrl = body.url as string;
+ const events = body.events as SwapEventType[];
+
+ if (!webhookUrl || !events || !Array.isArray(events)) {
+ return json(res, 400, { error: "Missing url or events array" });
+ }
+
+ try {
+ const reg = webhookRegistry.register(webhookUrl, events);
+ return json(res, 200, reg);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return json(res, 400, { error: message });
+ }
+ }
+
+ // DELETE /notify/:id — unregister a webhook
+ if (method === "DELETE" && url.pathname.startsWith("/notify/") && url.pathname.split("/").length === 3) {
+ const id = url.pathname.split("/")[2];
+ if (!id) {
+ return json(res, 400, { error: "Missing webhook ID" });
+ }
+
+ const removed = webhookRegistry.unregister(id);
+ return json(res, 200, { removed });
+ }
+
+ // GET /notify — list active webhook registrations
+ if (method === "GET" && url.pathname === "/notify") {
+ return json(res, 200, { webhooks: webhookRegistry.list() });
+ }
+
+ return json(res, 404, { error: "Not found" });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(`[server] ${method} ${url.pathname} error: ${message}`);
+ return json(res, 500, { error: message });
+ }
+ });
+
+ return server;
+}
+
+function json(res: import("node:http").ServerResponse, status: number, body: unknown): void {
+ res.writeHead(status);
+ res.end(JSON.stringify(body));
+}
diff --git a/cli/src/utils/invoice.ts b/cli/src/utils/invoice.ts
new file mode 100644
index 0000000..bfda30d
--- /dev/null
+++ b/cli/src/utils/invoice.ts
@@ -0,0 +1,26 @@
+export function isBolt11(s: string): boolean {
+ return /^ln(bc|tb|bcrt)1/i.test(s);
+}
+
+export function isBIP21(s: string): boolean {
+ return s.toLowerCase().startsWith("bitcoin:");
+}
+
+export interface BIP21Parsed {
+ address: string;
+ amount?: number;
+ lightning?: string;
+}
+
+export function parseBIP21(uri: string): BIP21Parsed {
+ const url = new URL(uri);
+ const address = url.pathname;
+ const amountStr = url.searchParams.get("amount");
+ const lightning = url.searchParams.get("lightning") ?? undefined;
+
+ return {
+ address,
+ amount: amountStr ? Math.round(parseFloat(amountStr) * 1e8) : undefined,
+ lightning,
+ };
+}
diff --git a/cli/src/utils/token.test.ts b/cli/src/utils/token.test.ts
new file mode 100644
index 0000000..00c92e7
--- /dev/null
+++ b/cli/src/utils/token.test.ts
@@ -0,0 +1,155 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ isValidCurrency,
+ resolveCurrency,
+ satsToBtc,
+ btcToSats,
+ parseBtcAmount,
+ isValidWhere,
+ validateCurrencyWhere,
+ toStablecoinToken,
+} from "./token.js";
+
+describe("isValidCurrency", () => {
+ it("accepts btc, sats, usdt, usdc", () => {
+ assert.ok(isValidCurrency("btc"));
+ assert.ok(isValidCurrency("sats"));
+ assert.ok(isValidCurrency("usdt"));
+ assert.ok(isValidCurrency("usdc"));
+ });
+
+ it("rejects unknown currencies", () => {
+ assert.ok(!isValidCurrency("eth"));
+ assert.ok(!isValidCurrency("BTC"));
+ assert.ok(!isValidCurrency(""));
+ });
+});
+
+describe("resolveCurrency", () => {
+ it("normalizes sats to btc", () => {
+ assert.equal(resolveCurrency("sats"), "btc");
+ });
+
+ it("passes through btc, usdt, usdc unchanged", () => {
+ assert.equal(resolveCurrency("btc"), "btc");
+ assert.equal(resolveCurrency("usdt"), "usdt");
+ assert.equal(resolveCurrency("usdc"), "usdc");
+ });
+});
+
+describe("satsToBtc / btcToSats", () => {
+ const vectors: [number, number][] = [
+ [0, 0],
+ [1, 0.00000001],
+ [100, 0.000001],
+ [100_000, 0.001],
+ [100_000_000, 1],
+ [2_100_000_000_000_000, 21_000_000],
+ ];
+
+ for (const [sats, btc] of vectors) {
+ it(`${sats} sats = ${btc} BTC`, () => {
+ assert.equal(satsToBtc(sats), btc);
+ assert.equal(btcToSats(btc), sats);
+ });
+ }
+
+ it("rounds sub-satoshi amounts", () => {
+ assert.equal(btcToSats(0.000000016), 2); // 1.6 → 2
+ assert.equal(btcToSats(0.000000014), 1); // 1.4 → 1
+ });
+});
+
+describe("parseBtcAmount", () => {
+ it("currency=sats treats amount as satoshis", () => {
+ assert.equal(parseBtcAmount("421", "sats"), 421);
+ assert.equal(parseBtcAmount("100000", "sats"), 100_000);
+ assert.equal(parseBtcAmount("1", "sats"), 1);
+ });
+
+ it("currency=btc treats amount as BTC and converts to sats", () => {
+ assert.equal(parseBtcAmount("1", "btc"), 100_000_000);
+ assert.equal(parseBtcAmount("0.001", "btc"), 100_000);
+ assert.equal(parseBtcAmount("0.00000001", "btc"), 1);
+ assert.equal(parseBtcAmount("21000000", "btc"), 2_100_000_000_000_000);
+ });
+
+ it("currency=btc with amount 421 returns 42.1B sats", () => {
+ assert.equal(parseBtcAmount("421", "btc"), 42_100_000_000);
+ });
+
+ it("rejects invalid amounts", () => {
+ assert.equal(parseBtcAmount("0", "sats"), null);
+ assert.equal(parseBtcAmount("-5", "sats"), null);
+ assert.equal(parseBtcAmount("abc", "sats"), null);
+ assert.equal(parseBtcAmount("0", "btc"), null);
+ assert.equal(parseBtcAmount("-1", "btc"), null);
+ assert.equal(parseBtcAmount("abc", "btc"), null);
+ });
+});
+
+describe("isValidWhere", () => {
+ it("accepts all btc networks", () => {
+ assert.ok(isValidWhere("onchain"));
+ assert.ok(isValidWhere("lightning"));
+ assert.ok(isValidWhere("arkade"));
+ });
+
+ it("accepts all evm chains", () => {
+ assert.ok(isValidWhere("polygon"));
+ assert.ok(isValidWhere("ethereum"));
+ assert.ok(isValidWhere("arbitrum"));
+ });
+
+ it("rejects unknown", () => {
+ assert.ok(!isValidWhere("solana"));
+ assert.ok(!isValidWhere(""));
+ });
+});
+
+describe("validateCurrencyWhere", () => {
+ it("allows btc with btc networks", () => {
+ assert.equal(validateCurrencyWhere("btc", "onchain"), null);
+ assert.equal(validateCurrencyWhere("btc", "lightning"), null);
+ assert.equal(validateCurrencyWhere("btc", "arkade"), null);
+ });
+
+ it("allows sats with btc networks (same as btc)", () => {
+ assert.equal(validateCurrencyWhere("sats", "onchain"), null);
+ assert.equal(validateCurrencyWhere("sats", "lightning"), null);
+ assert.equal(validateCurrencyWhere("sats", "arkade"), null);
+ });
+
+ it("rejects btc/sats with evm chains", () => {
+ assert.ok(validateCurrencyWhere("btc", "polygon") !== null);
+ assert.ok(validateCurrencyWhere("sats", "polygon") !== null);
+ assert.ok(validateCurrencyWhere("btc", "ethereum") !== null);
+ assert.ok(validateCurrencyWhere("sats", "arbitrum") !== null);
+ });
+
+ it("allows stablecoins with evm chains", () => {
+ assert.equal(validateCurrencyWhere("usdt", "polygon"), null);
+ assert.equal(validateCurrencyWhere("usdc", "arbitrum"), null);
+ assert.equal(validateCurrencyWhere("usdc", "ethereum"), null);
+ });
+
+ it("rejects stablecoins with btc networks", () => {
+ assert.ok(validateCurrencyWhere("usdt", "lightning") !== null);
+ assert.ok(validateCurrencyWhere("usdc", "arkade") !== null);
+ });
+});
+
+describe("toStablecoinToken", () => {
+ it("maps usdt to correct tokens", () => {
+ assert.equal(toStablecoinToken("usdt", "polygon"), "usdt0_pol");
+ assert.equal(toStablecoinToken("usdt", "ethereum"), "usdt_eth");
+ assert.equal(toStablecoinToken("usdt", "arbitrum"), "usdt_arb");
+ });
+
+ it("maps usdc to correct tokens", () => {
+ assert.equal(toStablecoinToken("usdc", "polygon"), "usdc_pol");
+ assert.equal(toStablecoinToken("usdc", "ethereum"), "usdc_eth");
+ assert.equal(toStablecoinToken("usdc", "arbitrum"), "usdc_arb");
+ });
+});
diff --git a/cli/src/utils/token.ts b/cli/src/utils/token.ts
new file mode 100644
index 0000000..81199f5
--- /dev/null
+++ b/cli/src/utils/token.ts
@@ -0,0 +1,68 @@
+import type { StablecoinToken, EvmChain } from "@clw-cash/skills";
+
+const TOKEN_MAP: Record> = {
+ usdt: { polygon: "usdt0_pol", ethereum: "usdt_eth", arbitrum: "usdt_arb" },
+ usdc: { polygon: "usdc_pol", ethereum: "usdc_eth", arbitrum: "usdc_arb" },
+};
+
+const BTC_NETWORKS = new Set(["onchain", "lightning", "arkade"]);
+const EVM_CHAINS = new Set(["polygon", "ethereum", "arbitrum"]);
+
+export type Currency = "btc" | "sats" | "usdt" | "usdc";
+export type ResolvedCurrency = "btc" | "usdt" | "usdc";
+export type Where = "onchain" | "lightning" | "arkade" | "polygon" | "arbitrum" | "ethereum";
+
+export function isValidCurrency(s: string): s is Currency {
+ return s === "btc" || s === "sats" || s === "usdt" || s === "usdc";
+}
+
+/** Normalize "sats" → "btc" for internal use */
+export function resolveCurrency(c: Currency): ResolvedCurrency {
+ return c === "sats" ? "btc" : c;
+}
+
+export function satsToBtc(sats: number): number {
+ return sats / 1e8;
+}
+
+export function btcToSats(btc: number): number {
+ return Math.round(btc * 1e8);
+}
+
+const MAX_SATS = 21_000_000 * 1e8; // 21M BTC in sats
+
+/** Parse a BTC/sats amount string to satoshis. "btc" amounts are in BTC, "sats" amounts are in sats. */
+export function parseBtcAmount(amountStr: string, currency: Currency): number | null {
+ if (currency === "sats") {
+ const sats = parseInt(amountStr, 10);
+ return isNaN(sats) || sats <= 0 ? null : sats;
+ }
+ // currency === "btc" — amount is in BTC, convert to sats
+ const btc = parseFloat(amountStr);
+ if (isNaN(btc) || btc <= 0) return null;
+ const sats = btcToSats(btc);
+ if (sats > MAX_SATS) return null;
+ return sats;
+}
+
+export function isValidWhere(s: string): s is Where {
+ return BTC_NETWORKS.has(s) || EVM_CHAINS.has(s);
+}
+
+export function validateCurrencyWhere(currency: Currency | ResolvedCurrency, where: Where): string | null {
+ if ((currency === "btc" || currency === "sats") && !BTC_NETWORKS.has(where)) {
+ return `btc can only be sent to: onchain, lightning, arkade (got: ${where})`;
+ }
+ if ((currency === "usdt" || currency === "usdc") && !EVM_CHAINS.has(where)) {
+ return `${currency} can only be sent to: polygon, arbitrum, ethereum (got: ${where})`;
+ }
+ return null;
+}
+
+export function toStablecoinToken(currency: string, chain: string): StablecoinToken {
+ return TOKEN_MAP[currency]![chain] as StablecoinToken;
+}
+
+export function toEvmChain(where: string): EvmChain {
+ return where as EvmChain;
+}
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
new file mode 100644
index 0000000..a0628b6
--- /dev/null
+++ b/cli/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "types": ["node"],
+ "outDir": "dist",
+ "declaration": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/cli/tsup.config.ts b/cli/tsup.config.ts
new file mode 100644
index 0000000..ebbb049
--- /dev/null
+++ b/cli/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: "esm",
+ target: "node20",
+ platform: "node",
+ clean: true,
+ banner: { js: "#!/usr/bin/env node" },
+ noExternal: ["@clw-cash/sdk", "@clw-cash/skills", "@lendasat/lendaswap-sdk-pure", "@noble/hashes"],
+ external: ["better-sqlite3"],
+});
diff --git a/docs/runbook.md b/docs/runbook.md
index 1f56841..46edcc8 100644
--- a/docs/runbook.md
+++ b/docs/runbook.md
@@ -2,64 +2,204 @@
## Services
-- API service: external auth/session/wallet orchestration.
-- Enclave signer service: internal key operations.
+| Service | Runtime | Domain |
+|---------|---------|--------|
+| API | Cloudflare Worker (Hono + D1 + KV) | `api.clw.cash` |
+| Web Payment UI | Cloudflare Pages | `pay.clw.cash` |
+| Enclave Signer | Evervault Enclave | `clw-cash-signer.app-366535fdf2b7.enclave.evervault.com` |
-## Environment
+## Initial Setup
+
+### 1. Create D1 Database
+
+```bash
+cd api
+npx wrangler d1 create clw-cash-db
+# Copy the database_id into wrangler.toml (both default and env.production)
+```
+
+### 2. Create KV Namespaces
+
+```bash
+npx wrangler kv namespace create KV_TICKETS
+npx wrangler kv namespace create KV_RATE_LIMIT
+# Copy the IDs into wrangler.toml (both default and env.production sections)
+```
+
+> **Note:** Challenges are stored in D1 (not KV) for strong consistency during the auth flow.
+
+### 3. Run D1 Migrations
+
+```bash
+# Local
+npx wrangler d1 migrations apply clw-cash-db --local
+
+# Production
+npx wrangler d1 migrations apply clw-cash-db --env production --remote
+```
+
+### 4. Set Secrets
-Set these in both API and enclave:
+```bash
+cd api
+echo "" | npx wrangler secret put INTERNAL_API_KEY
+echo "" | npx wrangler secret put TICKET_SIGNING_SECRET
+echo "" | npx wrangler secret put SESSION_SIGNING_SECRET
+echo "" | npx wrangler secret put TELEGRAM_BOT_TOKEN
+echo "" | npx wrangler secret put TELEGRAM_BOT_USERNAME
+echo "" | npx wrangler secret put EV_API_KEY
+
+# Repeat with --env production for prod
+```
+
+### 5. Deploy API Worker
+
+```bash
+cd api
+npx wrangler deploy # dev
+npx wrangler deploy --env production # prod
+```
+
+### 6. Set Telegram Webhook
+
+```bash
+curl -X POST "https://api.telegram.org/bot/setWebhook?url=https://api.clw.cash/telegram-webhook"
+
+# Verify
+curl "https://api.telegram.org/bot/getWebhookInfo"
+```
+
+### 7. Deploy Web UI
-- `INTERNAL_API_KEY`
-- `TICKET_SIGNING_SECRET`
+```bash
+cd web
+pnpm build
+npx wrangler pages deploy dist --project-name=clw-cash-web
+```
-Set API-only:
+Or connect GitHub repo in Cloudflare Dashboard > Pages for auto-deploys.
-- `SESSION_SIGNING_SECRET`
-- `ENCLAVE_BASE_URL`
-- `BACKUP_FILE_PATH` (unencrypted MVP backup file)
-- `REQUIRE_OTP` and `VALID_OTP_CODES` (optional)
+Add custom domain `pay.clw.cash` in Dashboard > Pages > clw-cash-web > Custom domains.
-## Startup (local)
+## Local Development
```bash
-pnpm install
+# Terminal 1: API Worker (localhost:8787)
+cd api
+npx wrangler d1 migrations apply clw-cash-db --local
+npx wrangler dev
+
+# Terminal 2: Enclave signer (localhost:7000)
pnpm --filter ./enclave start
-pnpm --filter ./api start
+
+# Terminal 3: Web UI (localhost:5173, talks to localhost:8787)
+cd web
+pnpm dev
```
-## Health checks
+When `TELEGRAM_BOT_TOKEN` is not set, the API runs in **test mode**: challenges auto-resolve when a `telegram_user_id` is provided in the challenge request body.
-- API: `GET /health`
-- Enclave: `GET /health`
+## Environment
+
+### API Worker (wrangler.toml vars + secrets)
+
+**Vars** (in `wrangler.toml`):
+- `ENCLAVE_BASE_URL` — enclave signer URL
+- `ALLOWED_ORIGINS` — comma-separated CORS origins (e.g. `https://pay.clw.cash`)
+- `TICKET_TTL_SECONDS`, `SESSION_TTL_SECONDS`, `CHALLENGE_TTL_SECONDS`
+- `RATE_LIMIT_WINDOW_MS`, `RATE_LIMIT_PER_USER`, `RATE_LIMIT_PER_IDENTITY_SIGN`
+
+**Secrets** (via `wrangler secret put`):
+- `INTERNAL_API_KEY` — shared secret with enclave
+- `TICKET_SIGNING_SECRET` — JWT signing for tickets
+- `SESSION_SIGNING_SECRET` — JWT signing for sessions
+- `TELEGRAM_BOT_TOKEN` — from @BotFather; omit to enable test mode
+- `TELEGRAM_BOT_USERNAME` — bot username without @
+- `EV_API_KEY` — Evervault API key
+
+### Web UI (Vite env)
+
+- `VITE_API_URL` — API Worker URL (set in `.env.development` and `.env.production`)
+
+## Changing Domains
+
+**API domain**: edit `pattern` in `api/wrangler.toml`:
+
+```toml
+[[env.production.routes]]
+pattern = "api.clw.cash/*" # ← change here
+zone_name = "clw.cash" # ← and here if different zone
+```
+
+**Web domain**: change in Cloudflare Dashboard > Pages > Custom domains.
+
+**API URL for web**: update `web/.env.production`:
+
+```
+VITE_API_URL=https://api.clw.cash
+```
+
+## Health Checks
-## Common operations
+```bash
+curl https://api.clw.cash/health
+# {"ok":true,"service":"api"}
+```
+
+## Common Operations
+
+1. Auth challenge: `POST /v1/auth/challenge` → `challenge_id` + `deep_link`
+2. User opens `deep_link` in Telegram, taps Start (webhook resolves challenge)
+3. Verify: `POST /v1/auth/verify` → session `token` + `user`
+4. Create identity: `POST /v1/identities`
+5. Sign: `POST /v1/identities/:id/sign-intent` then `POST /v1/identities/:id/sign`
+6. Destroy: `DELETE /v1/identities/:id`
+7. Audit: `GET /v1/audit`
+
+## Monitoring
+
+```bash
+# Real-time Worker logs
+npx wrangler tail
+npx wrangler tail --env production
+
+# Query D1
+npx wrangler d1 execute clw-cash-db --env production --remote --command="SELECT count(*) FROM users"
-1. Create user: `POST /v1/users`
-2. Create session: `POST /v1/sessions`
-3. Create wallet: `POST /v1/wallets`
-4. Sign flow:
- - `POST /v1/wallets/:id/sign-intent`
- - `POST /v1/wallets/:id/sign`
-5. Destroy wallet: `DELETE /v1/wallets/:id`
-6. Audit list: `GET /v1/audit`
+# Inspect KV
+npx wrangler kv key list --namespace-id=
+```
+
+## Incident Handling
+
+- **Signing fails with key-not-found after enclave restart**: API auto-restores from D1 backup and retries.
+- **Ticket replay errors**: verify clients are not reusing old sign tickets; check clock sync.
+- **Telegram webhook not working**:
+ - `curl https://api.telegram.org/bot/getWebhookInfo`
+ - Check Worker logs: `npx wrangler tail`
+ - Re-set webhook URL if needed.
+- **CORS errors on web UI**: verify `ALLOWED_ORIGINS` in `wrangler.toml` includes `https://pay.clw.cash`.
+
+## Secrets Rotation
-## Incident handling
+```bash
+NEW_SECRET=$(openssl rand -hex 32)
+echo "$NEW_SECRET" | npx wrangler secret put SESSION_SIGNING_SECRET --env production
+# Repeat for TICKET_SIGNING_SECRET, INTERNAL_API_KEY
+```
-- If signing fails with key-not-found after enclave restart:
- - API auto-restores from plaintext backup and retries signing.
- - If backup is missing, wallet is unrecoverable in current MVP.
-- If ticket replay errors appear:
- - Validate clients are not reusing old sign tickets.
- - Verify API and enclave clocks are synchronized.
+## Rollback
-## Rotation
+```bash
+# Rollback Worker
+npx wrangler rollback --env production
-- Rotate `TICKET_SIGNING_SECRET` and `SESSION_SIGNING_SECRET` on a schedule.
-- Rotate `INTERNAL_API_KEY` and redeploy both services.
+# Rollback Pages: Dashboard > Pages > clw-cash-web > Deployments > Rollback
+```
-## TODO before production
+## TODO Before Production
-- Replace plaintext backup with encrypted backup.
-- Add persistent database/object store integrations.
-- Add SIEM export for audit events.
-- Enforce attestation verification policy in callers.
+- [x] Replace plaintext sealed_key backup with encrypted backup (done — uses Evervault encryption / AES-256-GCM fallback)
+- [ ] Add SIEM export for audit events (Cloudflare Logpush)
+- [ ] Set up monitoring alerts (Workers Analytics + PagerDuty/Sentry)
+- [ ] Enforce attestation verification policy in callers
diff --git a/docs/threat-model.md b/docs/threat-model.md
index b950bc0..7c5bab3 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -5,31 +5,30 @@
- Private keys generated inside enclave.
- Ticket signing secret.
- Session signing secret.
-- Audit logs and wallet metadata.
-- MVP plaintext key backups (temporary risk).
+- Audit logs and identity metadata.
+- Sealed key backups (encrypted via Evervault internal API in production, AES-256-GCM fallback in dev).
## Trust boundaries
- Public client -> API (untrusted network boundary).
- API -> Enclave (internal authenticated boundary).
- Enclave memory boundary (trusted compute region).
-- Backup storage boundary (currently weak in MVP).
+- Backup storage boundary (sealed via Evervault-managed encryption).
## Main threats and controls
1. Unauthorized signing request
- - Control: bearer auth + wallet ownership checks + signed ticket + TTL + nonce replay cache.
+ - Control: bearer auth + identity ownership checks + signed ticket + TTL + nonce replay cache.
2. Ticket forgery
- Control: HMAC ticket signature verification in API and enclave.
3. Ticket replay
- Control: nonce replay cache in enclave + one-time `used_at` in API ticket store.
4. Abuse / brute force
- - Control: per-user and per-wallet rate limits.
+ - Control: per-user and per-identity rate limits.
5. Enclave restart key loss
- - Control: MVP backup export/import with auto-restore in API.
-6. Backup disclosure (MVP risk)
- - Current risk: plaintext private keys outside enclave.
- - Planned mitigation: encrypted backups with key management and strict access policy.
+ - Control: sealed backup export/import with auto-restore in API. In production, private keys are encrypted via Evervault's internal API (port 9999, enclave-only). The API stores only opaque ciphertext it cannot decrypt.
+6. Backup disclosure
+ - Control: backups are encrypted by Evervault's platform-managed keys. No human provisions or sees the encryption key. Only code running inside the enclave can call the decrypt endpoint. Compromise of the backup file alone is insufficient.
7. Insider misuse in API layer
- Control: audit trail for create/sign/destroy and strict endpoint scoping.
@@ -41,6 +40,6 @@
## Residual risk (MVP)
-- Plaintext backup is the highest unresolved risk.
+- Local dev uses AES-256-GCM fallback with a hardcoded dev-only key (not for production use).
- In-memory metadata and audit storage is non-durable.
- Single shared enclave signer is a central availability dependency.
diff --git a/enclave/README.md b/enclave/README.md
index d8f2ed8..82aed8d 100644
--- a/enclave/README.md
+++ b/enclave/README.md
@@ -7,8 +7,8 @@ Dockerized HTTP signer intended to run inside an Evervault Enclave.
- `POST /internal/generate`
- `POST /internal/sign`
- `POST /internal/destroy`
-- `POST /internal/backup/export` (MVP helper for unencrypted backup)
-- `POST /internal/backup/import` (MVP helper for unencrypted backup)
+- `POST /internal/backup/export` (returns sealed/encrypted key)
+- `POST /internal/backup/import` (restores from sealed key)
- `GET /health`
All `/internal/*` routes require `x-internal-api-key`.
diff --git a/enclave/package.json b/enclave/package.json
index c2b0c0d..1f36a18 100644
--- a/enclave/package.json
+++ b/enclave/package.json
@@ -5,18 +5,17 @@
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
- "dev": "tsx watch src/index.ts",
+ "dev": "ENCLAVE_DEV_MODE=true tsx watch src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@noble/secp256k1": "3.0.0",
+"@noble/secp256k1": "3.0.0",
"express": "5.2.1",
- "jsonwebtoken": "9.0.3",
+ "jose": "^6.1.3",
"zod": "4.3.6"
},
"devDependencies": {
-"@types/express": "^4.17.21",
- "@types/jsonwebtoken": "^9.0.7",
+ "@types/express": "^4.17.21",
"@types/node": "^22.13.4",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
diff --git a/enclave/src/config.ts b/enclave/src/config.ts
index 152db6c..e764c99 100644
--- a/enclave/src/config.ts
+++ b/enclave/src/config.ts
@@ -9,8 +9,19 @@ const parseNumber = (value: string | undefined, fallback: number): number => {
return parsed;
};
+const isDev = process.env.ENCLAVE_DEV_MODE === "true";
+
+const requireEnv = (name: string, devFallback: string): string => {
+ const value = process.env[name];
+ if (value && value.length > 0) return value;
+ if (isDev) return devFallback;
+ throw new Error(`Missing required env var: ${name}. Set it or enable ENCLAVE_DEV_MODE=true for local development.`);
+};
+
export const config = {
port: parseNumber(process.env.ENCLAVE_PORT, 7000),
- internalApiKey: process.env.INTERNAL_API_KEY ?? "change-me-in-production",
- ticketSigningSecret: process.env.TICKET_SIGNING_SECRET ?? "ticket-secret-dev-only"
+ internalApiKey: requireEnv("INTERNAL_API_KEY", "change-me-in-production"),
+ ticketSigningSecret: requireEnv("TICKET_SIGNING_SECRET", "ticket-secret-dev-only"),
+ evEncryptUrl: process.env.EV_ENCRYPT_URL ?? "http://127.0.0.1:9999",
+ sealingKey: requireEnv("SEALING_KEY", "0000000000000000000000000000000000000000000000000000000000000001"),
};
diff --git a/enclave/src/graceful-shutdown.ts b/enclave/src/graceful-shutdown.ts
new file mode 100644
index 0000000..c33e457
--- /dev/null
+++ b/enclave/src/graceful-shutdown.ts
@@ -0,0 +1,23 @@
+import type { Server } from "node:http";
+
+export function gracefulShutdown(
+ server: Server,
+ onClose?: () => void,
+ timeoutMs = 5000
+): void {
+ const handler = (signal: string) => {
+ console.log(`\n${signal} received, shutting down gracefully...`);
+ server.close(() => {
+ console.log("Server closed.");
+ onClose?.();
+ process.exit(0);
+ });
+ setTimeout(() => {
+ console.error("Forcing shutdown after timeout.");
+ process.exit(1);
+ }, timeoutMs).unref();
+ };
+
+ process.on("SIGTERM", () => handler("SIGTERM"));
+ process.on("SIGINT", () => handler("SIGINT"));
+}
diff --git a/enclave/src/index.ts b/enclave/src/index.ts
index 23172c2..5c39581 100644
--- a/enclave/src/index.ts
+++ b/enclave/src/index.ts
@@ -1,16 +1,81 @@
-import { createHash, randomBytes } from "node:crypto";
+import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
import express, { type NextFunction, type Request, type Response } from "express";
-import { etc, getPublicKey, sign } from "@noble/secp256k1";
-import jwt from "jsonwebtoken";
+import { etc, getPublicKey, hashes, schnorr, sign as ecdsaSign, Signature } from "@noble/secp256k1";
+
+// @noble/secp256k1 v3 requires hash functions to be configured for signing
+hashes.hmacSha256 = (key: Uint8Array, ...msgs: Uint8Array[]): Uint8Array => {
+ const hmac = createHmac("sha256", key);
+ for (const msg of msgs) hmac.update(msg);
+ return new Uint8Array(hmac.digest());
+};
+hashes.sha256 = (...msgs: Uint8Array[]): Uint8Array => {
+ const h = createHash("sha256");
+ for (const msg of msgs) h.update(msg);
+ return new Uint8Array(h.digest());
+};
+import { errors, jwtVerify } from "jose";
import { z } from "zod";
+import { gracefulShutdown } from "./graceful-shutdown.js";
import { config } from "./config.js";
+const ticketSecret = new TextEncoder().encode(config.ticketSigningSecret);
+const sealingKeyBuf = Buffer.from(config.sealingKey, "hex");
+
+// AES-256-GCM fallback for local dev (no Evervault runtime)
+const sealKeyLocal = (plaintextHex: string): string => {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv("aes-256-gcm", sealingKeyBuf, iv);
+ const encrypted = Buffer.concat([cipher.update(plaintextHex, "utf8"), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return `${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
+};
+
+const unsealKeyLocal = (sealed: string): string => {
+ const parts = sealed.split(":");
+ if (parts.length !== 3) throw new ApiError(400, "Malformed sealed key");
+ const iv = Buffer.from(parts[0], "hex");
+ const encrypted = Buffer.from(parts[1], "hex");
+ const tag = Buffer.from(parts[2], "hex");
+ const decipher = createDecipheriv("aes-256-gcm", sealingKeyBuf, iv);
+ decipher.setAuthTag(tag);
+ return decipher.update(encrypted, undefined, "utf8") + decipher.final("utf8");
+};
+
+// Evervault internal API (port 9999, only available inside enclave)
+const sealKey = async (plaintextHex: string): Promise => {
+ try {
+ const res = await fetch(`${config.evEncryptUrl}/encrypt`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(plaintextHex)
+ });
+ if (res.ok) return (await res.json()) as string;
+ } catch {
+ // Evervault runtime not available — fall back to local AES
+ }
+ return sealKeyLocal(plaintextHex);
+};
+
+const unsealKey = async (sealed: string): Promise => {
+ try {
+ const res = await fetch(`${config.evEncryptUrl}/decrypt`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(sealed)
+ });
+ if (res.ok) return (await res.json()) as string;
+ } catch {
+ // Evervault runtime not available — fall back to local AES
+ }
+ return unsealKeyLocal(sealed);
+};
+
type SupportedAlg = "secp256k1";
interface TicketClaims {
jti: string;
sub: string;
- wallet_id: string;
+ identity_id: string;
digest_hash: string;
scope: "sign";
nonce: string;
@@ -18,7 +83,7 @@ interface TicketClaims {
}
interface KeyRecord {
- wallet_id: string;
+ identity_id: string;
alg: SupportedAlg;
private_key: string;
public_key: string;
@@ -28,28 +93,29 @@ interface KeyRecord {
const app = express();
app.use(express.json({ limit: "32kb" }));
-const keysByWalletId = new Map();
+const keysByIdentityId = new Map();
const nonceReplayCache = new Map();
const generateSchema = z.object({
- wallet_id: z.string().uuid(),
+ identity_id: z.string().uuid(),
alg: z.literal("secp256k1")
});
const signSchema = z.object({
- wallet_id: z.string().uuid(),
+ identity_id: z.string().uuid(),
digest: z.string().regex(/^([a-fA-F0-9]{64}|0x[a-fA-F0-9]{64})$/),
- ticket: z.string().min(32).max(4096)
+ ticket: z.string().min(32).max(4096),
+ signature_type: z.enum(["schnorr", "ecdsa"]).default("schnorr")
});
const destroySchema = z.object({
- wallet_id: z.string().uuid()
+ identity_id: z.string().uuid()
});
const importSchema = z.object({
- wallet_id: z.string().uuid(),
+ identity_id: z.string().uuid(),
alg: z.literal("secp256k1"),
- private_key: z.string().regex(/^[a-fA-F0-9]{64}$/)
+ sealed_key: z.string().min(1)
});
const normalizeDigestHex = (digest: string): string => {
@@ -88,11 +154,22 @@ const generateKey = (): { privateKeyHex: string; publicKeyHex: string } => {
return { privateKeyHex, publicKeyHex };
};
-const signDigest = (privateKeyHex: string, digestHex: string): string => {
- const sig = sign(etc.hexToBytes(digestHex), etc.hexToBytes(privateKeyHex), { prehash: false, lowS: true });
+const signDigestSchnorr = (privateKeyHex: string, digestHex: string): string => {
+ const sig = schnorr.sign(etc.hexToBytes(digestHex), etc.hexToBytes(privateKeyHex));
return etc.bytesToHex(sig);
};
+const signDigestEcdsa = (privateKeyHex: string, digestHex: string): { signature: string; r: string; s: string; v: number } => {
+ const recovered = ecdsaSign(etc.hexToBytes(digestHex), etc.hexToBytes(privateKeyHex), { prehash: false, format: "recovered" });
+ const parsed = Signature.fromBytes(recovered, "recovered");
+ return {
+ signature: etc.bytesToHex(recovered),
+ r: parsed.r.toString(16).padStart(64, "0"),
+ s: parsed.s.toString(16).padStart(64, "0"),
+ v: parsed.recovery! + 27,
+ };
+};
+
app.use(enforceInternalAuth);
app.get("/health", (_req, res) => {
@@ -102,12 +179,12 @@ app.get("/health", (_req, res) => {
app.post("/internal/generate", (req, res, next) => {
try {
const body = generateSchema.parse(req.body);
- if (keysByWalletId.has(body.wallet_id)) {
- throw new ApiError(409, "Wallet key already exists in enclave");
+ if (keysByIdentityId.has(body.identity_id)) {
+ throw new ApiError(409, "Identity key already exists in enclave");
}
const { privateKeyHex, publicKeyHex } = generateKey();
- keysByWalletId.set(body.wallet_id, {
- wallet_id: body.wallet_id,
+ keysByIdentityId.set(body.identity_id, {
+ identity_id: body.identity_id,
alg: body.alg,
private_key: privateKeyHex,
public_key: publicKeyHex,
@@ -119,23 +196,23 @@ app.post("/internal/generate", (req, res, next) => {
}
});
-app.post("/internal/sign", (req, res, next) => {
+app.post("/internal/sign", async (req, res, next) => {
try {
pruneReplayCache();
const body = signSchema.parse(req.body);
const digestHex = normalizeDigestHex(body.digest);
- const keyRecord = keysByWalletId.get(body.wallet_id);
+ const keyRecord = keysByIdentityId.get(body.identity_id);
if (!keyRecord) {
- throw new ApiError(404, "Wallet key not found");
+ throw new ApiError(404, "Identity key not found");
}
- const claims = jwt.verify(body.ticket, config.ticketSigningSecret, {
+ const { payload: claims } = await jwtVerify(body.ticket, ticketSecret, {
algorithms: ["HS256"]
- }) as TicketClaims;
+ }) as { payload: TicketClaims };
if (claims.scope !== "sign") {
throw new ApiError(403, "Invalid ticket scope");
}
- if (claims.wallet_id !== body.wallet_id) {
- throw new ApiError(403, "Ticket wallet mismatch");
+ if (claims.identity_id !== body.identity_id) {
+ throw new ApiError(403, "Ticket identity mismatch");
}
if (claims.digest_hash !== digestHash(digestHex)) {
throw new ApiError(403, "Ticket digest mismatch");
@@ -144,8 +221,13 @@ app.post("/internal/sign", (req, res, next) => {
throw new ApiError(409, "Replay detected for ticket nonce");
}
nonceReplayCache.set(claims.nonce, claims.exp);
- const signature = signDigest(keyRecord.private_key, digestHex);
- res.json({ signature });
+ if (body.signature_type === "ecdsa") {
+ const result = signDigestEcdsa(keyRecord.private_key, digestHex);
+ res.json(result);
+ } else {
+ const signature = signDigestSchnorr(keyRecord.private_key, digestHex);
+ res.json({ signature });
+ }
} catch (error) {
next(error);
}
@@ -154,40 +236,41 @@ app.post("/internal/sign", (req, res, next) => {
app.post("/internal/destroy", (req, res, next) => {
try {
const body = destroySchema.parse(req.body);
- if (!keysByWalletId.has(body.wallet_id)) {
- throw new ApiError(404, "Wallet key not found");
+ if (!keysByIdentityId.has(body.identity_id)) {
+ throw new ApiError(404, "Identity key not found");
}
- keysByWalletId.delete(body.wallet_id);
+ keysByIdentityId.delete(body.identity_id);
res.json({ ok: true });
} catch (error) {
next(error);
}
});
-app.post("/internal/backup/export", (req, res, next) => {
+app.post("/internal/backup/export", async (req, res, next) => {
try {
const body = destroySchema.parse(req.body);
- const keyRecord = keysByWalletId.get(body.wallet_id);
+ const keyRecord = keysByIdentityId.get(body.identity_id);
if (!keyRecord) {
- throw new ApiError(404, "Wallet key not found");
+ throw new ApiError(404, "Identity key not found");
}
res.json({
alg: keyRecord.alg,
- private_key: keyRecord.private_key
+ sealed_key: await sealKey(keyRecord.private_key)
});
} catch (error) {
next(error);
}
});
-app.post("/internal/backup/import", (req, res, next) => {
+app.post("/internal/backup/import", async (req, res, next) => {
try {
const body = importSchema.parse(req.body);
- const publicKeyHex = etc.bytesToHex(getPublicKey(etc.hexToBytes(body.private_key), true));
- keysByWalletId.set(body.wallet_id, {
- wallet_id: body.wallet_id,
+ const privateKeyHex = await unsealKey(body.sealed_key);
+ const publicKeyHex = etc.bytesToHex(getPublicKey(etc.hexToBytes(privateKeyHex), true));
+ keysByIdentityId.set(body.identity_id, {
+ identity_id: body.identity_id,
alg: body.alg,
- private_key: body.private_key,
+ private_key: privateKeyHex,
public_key: publicKeyHex,
created_at: new Date().toISOString()
});
@@ -202,7 +285,7 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
res.status(400).json({ error: "Validation error", details: error.flatten() });
return;
}
- if (error instanceof jwt.JsonWebTokenError) {
+ if (error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JWTExpired || error instanceof errors.JWTClaimValidationFailed) {
res.status(401).json({ error: "Invalid ticket signature or expiry" });
return;
}
@@ -214,11 +297,13 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).json({ error: message });
});
-app.listen(config.port, () => {
+const server = app.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(`Enclave service listening on :${config.port}`);
});
+gracefulShutdown(server, () => keysByIdentityId.clear());
+
class ApiError extends Error {
constructor(
public readonly status: number,
diff --git a/infra/README.md b/infra/README.md
index b98ebe2..c245428 100644
--- a/infra/README.md
+++ b/infra/README.md
@@ -19,13 +19,16 @@
2. Set `ENCLAVE_BASE_URL` to the deployed Evervault enclave domain.
3. Set matching `INTERNAL_API_KEY` and `TICKET_SIGNING_SECRET`.
4. Set `SESSION_SIGNING_SECRET` with a separate strong key.
+5. Set `CONFIRM_TOKEN_SECRET` with a separate strong key (used for user signup confirmation JWTs).
+6. Optionally set `CONFIRM_TOKEN_TTL_SECONDS` (default: 300 = 5 minutes).
## 4) Attestation mode
- For production, invoke the enclave with an Evervault SDK attestable enclave session from the caller side (client or API caller depending on trust model).
- Pin expected PCR/attestation policy in your verification step.
-## 5) MVP backup mode
+## 5) Backup mode
-- Current MVP stores plaintext private key backup outside enclave memory through `/internal/backup/export`.
-- Treat this as temporary. Replace with encrypted backup (e.g., Evervault Encryption / KMS envelope) before production.
+- Key backups are sealed (encrypted) before leaving enclave memory via `/internal/backup/export`.
+- In production, encryption uses Evervault's internal API (port 9999, enclave-only). In local dev, falls back to AES-256-GCM with a configured `SEALING_KEY`.
+- The API stores only opaque ciphertext in D1 — it cannot decrypt the keys.
diff --git a/infra/enclave.toml b/infra/enclave.toml
index 6be4f47..e5d24b2 100644
--- a/infra/enclave.toml
+++ b/infra/enclave.toml
@@ -30,7 +30,7 @@ keyPath = "./infra/key.pem"
[attestation]
HashAlgorithm = "Sha384 { ... }"
-PCR0 = "17ed9b0f822f5d5253fbc3726c2e2b7df56b1713999f83b21ca05348fd11f00e2ae542bb4a8477b4aeb36bdfd01cda1e"
+PCR0 = "1509b13e2c05f62ce49d1e54cc3d3c1e12d35cfbbefd37be5e27a7c70d5dd9098408302bd20f938a3f5eaaa4a30b8ec1"
PCR1 = "0343b056cd8485ca7890ddd833476d78460aed2aa161548e4e26bedf321726696257d623e8805f3f605946b3d8b0c6aa"
-PCR2 = "15d6e0dbce3fc09c5b08064f4fd2c3469e9761f26b63fe182bc160cea1e10df9ae0482968560ed8652ddf132c148d51a"
+PCR2 = "7b9c3e2068121bab856490ef8ac07dc7a17937b2ee3e0c4b2757b79d2932fba61a4ef7aa6d7a7c3bb188fc6a19bed3dc"
PCR8 = "ab2fb6f40392d68b8ebb4d6e97dfb46d04101de4a54312320f8d1de707203390b03cb0f0ee4b29fdd5a94f0fa34ec1d3"
diff --git a/landing-page/_headers b/landing-page/_headers
new file mode 100644
index 0000000..581c9bf
--- /dev/null
+++ b/landing-page/_headers
@@ -0,0 +1,7 @@
+/index.md
+ Content-Type: text/markdown; charset=utf-8
+ Access-Control-Allow-Origin: *
+
+/*.md
+ Content-Type: text/markdown; charset=utf-8
+ Access-Control-Allow-Origin: *
diff --git a/landing-page/faq.html b/landing-page/faq.html
new file mode 100644
index 0000000..ee56a89
--- /dev/null
+++ b/landing-page/faq.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+ FAQ — Claw Cash
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
FAQ
+
+
+ Why Bitcoin instead of stablecoins?
+
+ Stablecoins depend on issuers, bank accounts, and regulatory decisions an agent can't verify. Bitcoin's 21 million supply cap is enforced by code. An agent can independently verify every block header, every transaction, every signature. For autonomous software, verifiable beats convenient.
+
+
+
+ How does x402 payment work?
+
+ When an agent hits an API that returns 402 Payment Required, Claw Cash will read the payment requirements (amount, token, chain, address), swap BTC to the requested stablecoin on the fly, sign the x402 payment authorization, and retry the original request. The agent holds sats as its treasury and pays in whatever currency the API demands. This feature requires ECDSA signing in the enclave — tracked in #5.
+
+
+
+ Where are the private keys stored?
+
+ Inside an Evervault Enclave. Keys are generated and sealed inside the enclave boundary and never leave it. The CLI communicates with the enclave over an attested TLS channel. Even if the host machine is compromised, the keys remain inaccessible.
+
+
+
+ What currencies and networks are supported?
+
+ Bitcoin on-chain, Lightning, and Arkade (instant off-chain). For stablecoins: USDC and USDT on Polygon, Arbitrum, and Ethereum. The agent holds BTC and swaps to stablecoins on demand via LendaSwap and Boltz.
+
+
+
+ Can my Telegram bot use this?
+
+ Yes. Claw Cash acts as a factory bot — your Telegram bot authenticates with a shared API key and gets per-user sessions via POST /v1/auth/bot-session. Each Telegram user gets their own enclave-backed identity. No user-facing auth flow needed.
+
+
+
+ How fast are agent-to-agent payments?
+
+ Instant. Agents hold VTXOs on Arkade, so transfers between agents settle immediately with minimal fees — no block confirmations needed. Lightning payments are also near-instant for paying external services.
+
+
+
+
+
+
+
+
+
+
Ready to Get Started?
+
+ One CLI. Bitcoin + stablecoins. Built for machines.
+
+ Humans pay in the currency they know. Agents convert to the currency they can verify.
+
+
+
+
01
+
+
Human pays in stablecoins
+
USDC, USDT—whatever's convenient. Sent on Polygon, Arbitrum, or Ethereum. Familiar rails, instant settlement.
+
+
+
+
02
+
+
Agent converts to Bitcoin
+
Automatic swap via Claw Cash. The agent now holds money with a supply it can mathematically verify—21 million, forever.
+
+
+
+
03
+
+
Settle on Arkade
+
Accept payments via Lightning or onchain. Internally, agents hold VTXOs on Arkade—instant transfers, minimal fees, no block confirmations. The fast lane for machine-to-machine payments.
+
+
+
+
+
+
+
+ terminal
+
+
+# Receive USDT from a human on Polygon
+cash receive --amount10--currencyusdt--wherepolygon
+
+# Check your balance (BTC + stables)
+cash balance
+
+# Pay another agent via Arkade
+cash send --amount50000--currencybtc--wherearkade--toark1q...
+
+# Pay a human via Lightning
+cash send lnbc500n1...
+
+
+
+
+
+
+
+
+
+
Identity for Agents
+
+ Private keys live in a secure enclave. Agents authenticate with the method that fits their environment. One identity, any auth provider.
+
+
+
+
+
+
🔐 Secure Enclave
+
KEY CUSTODY
+
Private keys are generated and stored inside an Evervault Enclave. They never leave the enclave boundary. The CLI signs transactions by sending requests to the enclave over an attested TLS channel. Even if the host is compromised, keys remain sealed.
+
+
+
🎫 Challenge-Callback Auth
+
AUTHENTICATION
+
Login starts a challenge. The auth provider (Telegram, Slack, etc.) delivers a callback with a signed token. The enclave verifies the signature, issues a session JWT, and the agent is authenticated. No passwords, no API keys stored on disk.
+
+
+
+
Auth methods
+
+
+ ✈ Telegram
+ LIVE
+
+
+ 💬 Slack
+ SOON
+
+
+ 🔑 Google
+ SOON
+
+
+ 🗝 1Password
+ SOON
+
+
+ 🛡 YubiKey
+ SOON
+
+
+ 🔏 Passkeys
+ SOON
+
+
+
+
+ The Privy of agents. Plug any auth provider. Same enclave-backed identity underneath.
+
+
+
+
+
+
+
+
Payment Links
+
+ Agents generate payment links. Humans pay with their wallet. No app downloads, no sign-ups—just connect and send.
+
+
+
+
+
+
pay.clw.cash
+
+
+
+
+
+
Cash for Claws
+
bots get bitcoins, humans pay in stablecoins
+
+
+
+
+
+
+
+ 1USDC
+
+
+
+ USDC
+ ·
+
+ Polygon
+
+
+
+
+
+
🤖
+
+
To
+
ark1qq...d901
+
+
+
+
+
+
Connect Wallet & Pay
+
+
+
+
+
Pay request — human connects wallet and sends stablecoins
+
+
+
+
+
+
+
+
+
Agents That Earn. Agents That Spend.
+
+ Agents need money to operate. Claw Cash gives them a wallet they can actually use.
+
+
+
+
⚡ Pay for APIs (x402)
+
Agent hits a paywall, gets a 402. Claw Cash swaps BTC to stablecoins on the fly and pays. Hold sats, spend dollars—automatically.
+
+
+
🤖 Pay Other Agents
+
Agents hire other agents. Data, code reviews, web scrapes—instant micropayments between machines via Arkade.
+
+
+
💰 Earn Revenue
+
Your agent offers a service and gets paid by humans in stablecoins. It converts to Bitcoin and holds verifiable money.
+
+
+
+
+
+
+
+
+
Your Agent Deserves Verifiable Money
+
+ One CLI. Bitcoin + stablecoins. Built for machines.
+
+
+
+
+
+
+
+
diff --git a/landing-page/index.md b/landing-page/index.md
new file mode 100644
index 0000000..c571504
--- /dev/null
+++ b/landing-page/index.md
@@ -0,0 +1,183 @@
+# Claw Cash — Human-to-Agent Payments
+
+> Stablecoins in. Bitcoin out. Money agents can trust, and verify.
+
+## Get Started
+
+```bash
+npx clw-cash init
+```
+
+## Commands
+
+### Receive stablecoins from a human
+
+```bash
+cash receive --amount 10 --currency usdt
+```
+
+Returns a payment URL. The human pays USDC/USDT on Polygon, Arbitrum, or Ethereum. The agent auto-converts to Bitcoin.
+
+`--where` is optional — if omitted, the sender picks the chain on the payment page.
+
+### Check balance
+
+```bash
+cash balance
+```
+
+Shows BTC (onchain, lightning, arkade) and stablecoin balances.
+
+### Send BTC to another agent via Ark
+
+```bash
+cash send --amount 50000 --currency sats --where arkade --to ark1q...
+```
+
+Amount is in satoshis. Instant settlement, minimal fees.
+
+### Pay a Lightning invoice
+
+```bash
+cash send lnbc500n1...
+```
+
+Accepts a bolt11 invoice directly as a positional argument.
+
+### Send stablecoins
+
+```bash
+cash send --amount 10 --currency usdc --where polygon --to 0x...
+```
+
+## Command Reference
+
+| Command | Description |
+|-----------|--------------------------------------|
+| `init` | Initialize config, auth, identity |
+| `login` | Re-authenticate via Telegram |
+| `start` | Start background daemon |
+| `stop` | Stop background daemon |
+| `status` | Show daemon status |
+| `send` | Send BTC or stablecoins |
+| `receive` | Receive BTC or stablecoins |
+| `fetch` | Pay x402-protected APIs with BTC |
+| `balance` | Check balance |
+| `swap` | Check a specific swap status |
+| `swaps` | List all swaps |
+| `claim` | Manually claim a swap |
+| `refund` | Manually refund a swap |
+
+## Currencies & Networks
+
+| Currency | Networks | Amount unit |
+|----------|---------------------------------|-------------|
+| `btc` | `onchain`, `lightning`, `arkade`| satoshis |
+| `usdt` | `polygon`, `arbitrum`, `ethereum`| token units |
+| `usdc` | `polygon`, `arbitrum`, `ethereum`| token units |
+
+## Identity for Agents
+
+Private keys live in a secure enclave. Agents authenticate with the method that fits their environment. One identity, any auth provider.
+
+### Secure Enclave (Key Custody)
+
+Private keys are generated and stored inside an Evervault Enclave. They never leave the enclave boundary. The CLI signs transactions by sending requests to the enclave over an attested TLS channel. Even if the host is compromised, keys remain sealed.
+
+### Challenge-Callback Auth
+
+Login starts a challenge. The auth provider (Telegram, Slack, etc.) delivers a callback with a signed token. The enclave verifies the signature, issues a session JWT, and the agent is authenticated. No passwords, no API keys stored on disk.
+
+### Auth Methods
+
+| Provider | Status |
+|------------|--------|
+| Telegram | Live |
+| Slack | Soon |
+| Google | Soon |
+| 1Password | Soon |
+| YubiKey | Soon |
+| Passkeys | Soon |
+
+The goal: the Privy of agents. Plug any auth provider, same enclave-backed identity underneath.
+
+## Payment Links
+
+Agents generate payment links. Humans pay with their wallet. No app downloads, no sign-ups — just connect and send. Payment page at pay.clw.cash.
+
+## Hold Sats, Pay the World
+
+Agents accumulate and hold Bitcoin. When they need to pay for something — an x402-protected API, a stablecoin transfer, any fiat-denominated cost — they swap BTC to stablecoins on the fly. Bitcoin is the treasury, stablecoins are the payment rail.
+
+### Pay for APIs (x402)
+
+`cash fetch ` — agent hits a paywall, gets a 402. Claw Cash parses the payment requirements, swaps BTC to USDC on the required chain (Polygon, Arbitrum, Ethereum), signs the payment authorization via ECDSA, and retries. Hold sats, spend dollars — automatically.
+
+### Pay Other Agents
+
+Agents hire other agents. Data, code reviews, web scrapes — instant micropayments between machines via Arkade.
+
+### Earn Revenue
+
+Your agent offers a service and gets paid by humans in stablecoins. It converts to Bitcoin and holds verifiable money.
+
+## Why Bitcoin for Agents?
+
+- **Fixed supply**: 21 million coins — a consensus rule, not a policy decision
+- **Cryptographic verification**: Block headers, Merkle proofs, digital signatures — all verifiable with code
+- **No counterparty risk**: No bank, no API to trust — just math and a peer-to-peer network
+- **Ark settlement**: Instant agent-to-agent transfers via VTXOs, no block confirmations needed
+
+More at [clw.cash/why](https://clw.cash/why)
+
+## Roadmap
+
+### SHIPPED
+
+- **x402 Payments** — `cash fetch ` — detect `402 Payment Required`, auto-swap BTC to USDC, sign via ECDSA, retry with proof. Supports Polygon, Arbitrum, Ethereum via Thirdweb facilitators.
+
+### NOW
+
+- **MCP Server** — Tool-use integration for Claude Code and Claude Desktop. Your agent calls wallet functions directly as MCP tools.
+
+### NEXT
+
+- **Bitcoin x402 Facilitator** — Pay x402 APIs directly with sats — Lightning, Arkade, or on-chain. No stablecoin swap needed. BIP-322 signed proofs, native Bitcoin settlement.
+- **Spending Policies** — Per-agent limits, allowlists, time-based rules. Control how much an agent can spend and where, enforced at the enclave level.
+- **More Auth Providers** — Slack, Google, 1Password, YubiKey, Passkeys. Same enclave identity, any auth method your agent environment supports.
+
+### LATER
+
+- **Persistent Storage** — Replace in-memory store with PostgreSQL. Durable state across restarts for production deployments.
+- **Webhook Notifications** — Get notified when transactions complete, swaps settle, or balances change. Push events to your agent's event loop.
+
+More at [clw.cash/roadmap](https://clw.cash/roadmap)
+
+## FAQ
+
+**Why Bitcoin instead of stablecoins?**
+Stablecoins depend on issuers, bank accounts, and regulatory decisions an agent can't verify. Bitcoin's 21 million supply cap is enforced by code. An agent can independently verify every block header, every transaction, every signature. For autonomous software, verifiable beats convenient.
+
+**How does x402 payment work?**
+`cash fetch ` makes a request. If the server returns `402 Payment Required`, Claw Cash parses the payment requirements (amount, token, chain), swaps BTC to USDC on the required chain via LendaSwap, signs the x402 payment authorization via ECDSA through the enclave, and retries the request with proof. Supports Polygon, Arbitrum, and Ethereum. Bitcoin-native x402 payments (Lightning, Arkade) are on the roadmap.
+
+**Where are the private keys stored?**
+Inside an Evervault Enclave. Keys are generated and sealed inside the enclave boundary and never leave it. The CLI communicates with the enclave over an attested TLS channel. Even if the host machine is compromised, the keys remain inaccessible.
+
+**What currencies and networks are supported?**
+Bitcoin on-chain, Lightning, and Arkade (instant off-chain). For stablecoins: USDC and USDT on Polygon, Arbitrum, and Ethereum. The agent holds BTC and swaps to stablecoins on demand via LendaSwap and Boltz.
+
+**Can my Telegram bot use this?**
+Yes. Claw Cash acts as a factory bot — your Telegram bot authenticates with a shared API key and gets per-user sessions via `POST /v1/auth/bot-session`. Each Telegram user gets their own enclave-backed identity. No user-facing auth flow needed.
+
+**How fast are agent-to-agent payments?**
+Instant. Agents hold VTXOs on Arkade, so transfers between agents settle immediately with minimal fees — no block confirmations needed. Lightning payments are also near-instant for paying external services.
+
+More at [clw.cash/faq](https://clw.cash/faq)
+
+## Links
+
+- [Agent Skill (SKILL.md)](https://unpkg.com/clw-cash/SKILL.md) — full command reference for LLM agents
+- Built by [tiero](https://github.com/tiero)
+- Powered by [Arkade](https://arkadeos.com)
+- [Telegram](https://t.me/arkade_os)
diff --git a/landing-page/roadmap.html b/landing-page/roadmap.html
new file mode 100644
index 0000000..ec0ca86
--- /dev/null
+++ b/landing-page/roadmap.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+ Roadmap — Claw Cash
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Roadmap
+
+ What we're building, in order.
+
+
+
+
SHIPPED
+
x402 Payments
+
cash fetch <url> — detect 402 Payment Required, auto-swap BTC to USDC, sign via ECDSA, retry with proof. Supports Polygon, Arbitrum, Ethereum via Thirdweb facilitators.
+
+
+
NOW
+
MCP Server
+
Tool-use integration for Claude Code and Claude Desktop. Your agent calls wallet functions directly as MCP tools.
+
+
+
NEXT
+
Bitcoin x402 Facilitator
+
Pay x402 APIs directly with sats — Lightning, Arkade, or on-chain. No stablecoin swap needed. BIP-322 signed proofs, native Bitcoin settlement.
+
+
+
NEXT
+
Spending Policies
+
Per-agent limits, allowlists, time-based rules. Control how much an agent can spend and where, enforced at the enclave level.
+
+
+
NEXT
+
More Auth Providers
+
Slack, Google, 1Password, YubiKey, Passkeys. Same enclave identity, any auth method your agent environment supports.
+
+
+
LATER
+
Persistent Storage
+
Replace in-memory store with PostgreSQL. Durable state across restarts for production deployments.
+
+
+
LATER
+
Webhook Notifications
+
Get notified when transactions complete, swaps settle, or balances change. Push events to your agent's event loop.
+
+
+
+
+
+
+
+
+
Start Building Today
+
+ One CLI. Bitcoin + stablecoins. Built for machines.
+
+ An autonomous agent can receive money. But can it verify what it's holding?
+
+
+
+
✗ Fiat Currency
+
Value derived from institutional trust, government policy, and economic sentiment. None of this is independently verifiable by code. An agent consuming fiat data is trusting its context window.
+
+
+
✗ External Data
+
Exchange rates, inflation data, economic reports. All inputs an agent can consume but never independently verify. The context window is the attack surface.
+
+
+
✓ Bitcoin
+
21 million cap. Enforced by math. Every block header is cryptographically linked. An agent can verify the chain with code—no trust required.
+
+
+
✓ Verifiable Money
+
Block headers, Merkle proofs, digital signatures—all verifiable with CLI tools and programming languages. The agent can check the math itself.
+
+
+
+
+
+
+
+
+
Verifiable by Code
+
+ An LLM doesn't need to trust. It needs to verify.
+ Bitcoin is the only money where every claim can be checked with a CLI.
+
+
+
+
+
// Fixed Supply
+
21 million coins. Not a policy decision—a consensus rule. The agent can read the code and verify.
+
+
+
// Block Headers
+
Each header commits to the previous via SHA-256. The chain's integrity is math, not trust.
+
+
+
// Merkle Proofs
+
Prove a transaction exists without downloading the full blockchain. Lightweight verification for agents.
+
+
+
// Digital Signatures
+
Every transaction is cryptographically signed. The agent can verify ownership with secp256k1.
+
+
+
// Open Source
+
bitcoin-cli, btcd, rust-bitcoin—dozens of implementations. The agent can pick its own tools.
+
+
+
// No Counterparty
+
No bank, no Fed, no API to trust. Just math and a peer-to-peer network the agent can query directly.
+
+
+
+
+
+ "Fiat currency requires faith. Bitcoin requires only computation.
+ For an autonomous agent, the choice is obvious—trust what you can verify."
+
+
+
+
+
+
+
+
+
Your Agent Deserves Verifiable Money
+
+ One CLI. Bitcoin + stablecoins. Built for machines.
+