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

Ready to Get Started?

+

+ One CLI. Bitcoin + stablecoins. Built for machines. +

+ +
+
+ + + + diff --git a/landing-page/index.html b/landing-page/index.html new file mode 100644 index 0000000..3c40892 --- /dev/null +++ b/landing-page/index.html @@ -0,0 +1,345 @@ + + + + + + Claw Cash — Bitcoin for Agents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
+ Human-to-Agent Payments +
+

Claw Cash

+

+ Stablecoins in. Bitcoin out.
+ Money agents can trust, and verify. +

+
+
+ $ + npx clw-cash init + +
+ Agent Skill (SKILL.md) +
+
+
+ + +
+
+

How It Works

+

+ 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 --amount 10 --currency usdt --where polygon + +# Check your balance (BTC + stables) +cash balance + +# Pay another agent via Arkade +cash send --amount 50000 --currency btc --where arkade --to ark1q... + +# 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. +

+
+
+ + + + + +
+
+

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

+
+
+ $ + npx clw-cash init + +
+ + Author + +
+
+
+ + + + + + 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 + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+

Start Building Today

+

+ One CLI. Bitcoin + stablecoins. Built for machines. +

+ +
+
+ + + + diff --git a/landing-page/style.css b/landing-page/style.css new file mode 100644 index 0000000..d7e8cfa --- /dev/null +++ b/landing-page/style.css @@ -0,0 +1,958 @@ +:root { + --orange: #f7931a; + --orange-glow: rgba(247, 147, 26, 0.3); + --dark: #0a0a0a; + --darker: #050505; + --gray: #888; + --light: #e5e5e5; + --code-bg: #141414; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, sans-serif; + background: var(--darker); + color: var(--light); + line-height: 1.6; + min-height: 100vh; +} + +.grain { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0.03; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); +} + +.container { + max-width: 900px; + margin: 0 auto; + padding: 0 24px; +} + +/* Nav */ +nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: rgba(5, 5, 5, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid #1a1a1a; +} + +nav .container { + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; +} + +.nav-logo { + font-size: 18px; + font-weight: 700; + color: var(--light); + text-decoration: none; +} + +.nav-logo .claw { + color: var(--orange); +} + +.nav-links { + display: flex; + gap: 32px; + list-style: none; +} + +.nav-links a { + color: var(--gray); + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: color 0.2s; +} + +.nav-links a:hover, +.nav-links a.active { + color: var(--light); +} + +/* Hero */ +.hero { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 80px 0; + position: relative; +} + +.hero::before { + content: ''; + position: absolute; + top: 20%; + left: 50%; + transform: translateX(-50%); + width: 600px; + height: 600px; + background: radial-gradient(circle, var(--orange-glow) 0%, transparent 70%); + pointer-events: none; + opacity: 0.4; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--code-bg); + border: 1px solid #222; + padding: 8px 16px; + border-radius: 100px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--gray); + margin-bottom: 32px; + width: fit-content; +} + +.badge span { + color: var(--orange); +} + +h1 { + font-size: clamp(48px, 10vw, 80px); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.05; + margin-bottom: 24px; +} + +h1 .claw { + color: var(--orange); +} + +.tagline { + font-size: clamp(20px, 3.5vw, 28px); + color: var(--gray); + max-width: 600px; + margin-bottom: 48px; +} + +.tagline strong { + color: var(--light); + font-weight: 600; +} + +.cta-group { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 16px 32px; + border-radius: 8px; + font-weight: 600; + font-size: 16px; + text-decoration: none; + transition: all 0.2s; +} + +.btn-primary { + background: var(--orange); + color: var(--dark); +} + +.btn-primary:hover { + background: #ffa033; + transform: translateY(-2px); +} + +.btn-secondary { + background: var(--code-bg); + color: var(--light); + border: 1px solid #333; +} + +.btn-secondary:hover { + border-color: var(--orange); +} + +/* Terminal inline block */ +.terminal-inline { + display: inline-flex; + align-items: center; + gap: 12px; + background: var(--code-bg); + border: 1px solid #333; + border-radius: 8px; + padding: 14px 20px; + font-family: 'JetBrains Mono', monospace; + font-size: 15px; + cursor: pointer; + transition: border-color 0.2s; +} + +.terminal-inline:hover { + border-color: var(--orange); +} + +.terminal-prompt { + color: var(--orange); + user-select: none; +} + +.terminal-cmd { + color: var(--light); +} + +.copy-btn { + background: none; + border: none; + color: var(--gray); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + transition: color 0.2s; +} + +.copy-btn:hover { + color: var(--orange); +} + +.copy-btn.copied { + color: #4ade80; +} + +/* Sections */ +section { + padding: 120px 0; + border-top: 1px solid #1a1a1a; +} + +h2 { + font-size: clamp(32px, 5vw, 48px); + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 24px; +} + +.section-lead { + font-size: 18px; + color: var(--gray); + max-width: 600px; + margin-bottom: 64px; +} + +/* Page header (for subpages) */ +.page-header { + padding-top: 120px; + padding-bottom: 0; +} + +/* Problem cards */ +.problem-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.problem-card { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 12px; + padding: 32px; +} + +.problem-card.bad { + border-color: #3a1a1a; +} + +.problem-card.good { + border-color: #1a2a1a; +} + +.problem-card h3 { + font-size: 20px; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.problem-card p { + color: var(--gray); + font-size: 15px; +} + +.icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.icon.red { background: #3a1a1a; } +.icon.green { background: #1a3a1a; } + +/* How it works flow */ +.flow { + display: flex; + flex-direction: column; + gap: 0; + position: relative; +} + +.flow-step { + display: grid; + grid-template-columns: 60px 1fr; + gap: 24px; + padding: 32px 0; + border-bottom: 1px solid #1a1a1a; +} + +.flow-step:last-child { + border-bottom: none; +} + +.step-num { + width: 48px; + height: 48px; + background: var(--code-bg); + border: 1px solid #333; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-family: 'JetBrains Mono', monospace; + font-weight: 700; + color: var(--orange); +} + +.flow-step h3 { + font-size: 20px; + margin-bottom: 8px; +} + +.flow-step p { + color: var(--gray); +} + +/* Code block */ +.code-section { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 12px; + overflow: hidden; + margin-top: 48px; +} + +.code-header { + background: #1a1a1a; + padding: 12px 20px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--gray); + display: flex; + align-items: center; + gap: 12px; +} + +.code-dots { + display: flex; + gap: 6px; +} + +.code-dots span { + width: 12px; + height: 12px; + border-radius: 50%; + background: #333; +} + +.code-body { + padding: 24px; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + line-height: 1.8; + overflow-x: auto; + white-space: pre; +} + +.code-body .comment { color: #666; } +.code-body .cmd { color: var(--orange); } +.code-body .flag { color: #888; } +.code-body .value { color: #7ec699; } + +/* Security & Auth */ +.auth-intro { + text-align: center; + max-width: 600px; + margin: 0 auto 64px; +} + +.auth-intro h2 { + margin-bottom: 24px; +} + +.auth-pillars { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 64px; +} + +.pillar { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 12px; + padding: 32px; +} + +.pillar h3 { + font-size: 18px; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 10px; +} + +.pillar h3 span { + font-size: 22px; +} + +.pillar .pillar-tag { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--orange); + margin-bottom: 12px; +} + +.pillar p { + color: var(--gray); + font-size: 15px; +} + +.auth-methods-label { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--gray); + text-align: center; + margin-bottom: 24px; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.auth-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-bottom: 16px; +} + +.auth-chip { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--code-bg); + border: 1px solid #333; + padding: 10px 20px; + border-radius: 100px; + font-size: 14px; + font-weight: 500; + color: var(--light); + transition: border-color 0.2s; +} + +.auth-chip.live { + border-color: var(--orange); +} + +.auth-chip.soon { + opacity: 0.45; +} + +.auth-chip .chip-icon { + font-size: 18px; + line-height: 1; +} + +.auth-chip .chip-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + background: #1a2a1a; + color: #4ade80; + padding: 2px 6px; + border-radius: 4px; + margin-left: 4px; +} + +.auth-chip.soon .chip-badge { + background: #1a1a2a; + color: #888; +} + +.auth-vision { + text-align: center; + margin-top: 32px; + font-size: 15px; + color: var(--gray); +} + +.auth-vision strong { + color: var(--light); +} + +/* Payment Links */ +.payment-grid { + display: flex; + justify-content: center; + margin-top: 48px; +} + +.payment-preview { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 16px; + overflow: hidden; + width: 100%; +} + +.payment-preview-header { + background: #1a1a1a; + padding: 12px 20px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--gray); + display: flex; + align-items: center; + gap: 12px; +} + +.payment-preview-url { + background: #0f0f0f; + padding: 4px 12px; + border-radius: 6px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pay-mockup { + background: #f5f5f5; + padding: 32px 24px; + display: flex; + flex-direction: column; + align-items: center; +} + +.pay-card { + background: #fff; + border-radius: 16px; + padding: 32px 28px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 24px rgba(0,0,0,0.06); + width: 100%; + max-width: 370px; + color: #111; +} + +.pay-brand { + display: flex; + justify-content: space-between; + margin-bottom: 32px; +} + +.pay-brand-name { + font-size: 15px; + font-weight: 700; + color: #111; +} + +.pay-brand-sub { + font-size: 12px; + color: #aaa; + margin-top: 2px; +} + +.pay-brand-sub em { + font-style: normal; + color: #666; +} + +.pay-amount { + text-align: center; + margin-bottom: 28px; +} + +.pay-amount-value { + font-size: 56px; + font-weight: 700; + color: #111; + letter-spacing: -3px; + line-height: 1; +} + +.pay-amount-cur { + font-size: 24px; + font-weight: 600; + color: #aaa; + vertical-align: super; + margin-left: 4px; +} + +.pay-amount-meta { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 13px; + color: #666; +} + +.pay-dot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} + +.pay-meta-sep { color: #e8e8e8; } + +.pay-recipient { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 8px; + margin-bottom: 24px; +} + +.pay-recipient-left { + display: flex; + align-items: center; + gap: 10px; +} + +.pay-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: #111; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.pay-recipient-label { + font-size: 11px; + color: #aaa; +} + +.pay-recipient-addr { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: #666; +} + +.pay-btn-mock { + width: 100%; + padding: 14px; + font-size: 14px; + font-weight: 600; + color: #fff; + background: #111; + border: none; + border-radius: 12px; + text-align: center; +} + +.pay-footer-mock { + text-align: center; + padding: 20px 0 4px; + font-size: 12px; + color: #666; +} + +.pay-footer-mock strong { + color: #111; + font-weight: 600; +} + +.payment-label { + padding: 16px 20px; + font-size: 14px; + color: var(--gray); + border-top: 1px solid #222; +} + +.payment-label strong { + color: var(--light); +} + +/* Use Cases */ +.usecase-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; + margin-top: 48px; +} + +.usecase-card { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 12px; + padding: 32px; +} + +.usecase-card h3 { + font-size: 18px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 10px; +} + +.usecase-card h3 span { + font-size: 24px; +} + +.usecase-card p { + color: var(--gray); + font-size: 15px; +} + +.usecase-card .example { + margin-top: 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: #666; + font-style: italic; +} + +/* Why Bitcoin / Verify grid */ +.verify-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 32px; + margin-top: 48px; +} + +.verify-item { + padding: 24px; + background: var(--code-bg); + border-radius: 12px; + border: 1px solid #222; +} + +.verify-item h4 { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + color: var(--orange); + margin-bottom: 12px; +} + +.verify-item p { + color: var(--gray); + font-size: 15px; +} + +/* FAQ */ +.faq-list { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 48px; +} + +.faq-item { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 12px; + overflow: hidden; +} + +.faq-item summary { + padding: 24px 32px; + font-size: 17px; + font-weight: 600; + cursor: pointer; + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; +} + +.faq-item summary::-webkit-details-marker { display: none; } + +.faq-item summary::after { + content: '+'; + font-family: 'JetBrains Mono', monospace; + font-size: 20px; + color: var(--orange); + transition: transform 0.2s; +} + +.faq-item[open] summary::after { + content: '\2212'; +} + +.faq-item .faq-answer { + padding: 0 32px 24px; + color: var(--gray); + font-size: 15px; + line-height: 1.7; +} + +.faq-item .faq-answer code { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + background: #1a1a1a; + padding: 2px 6px; + border-radius: 4px; + color: var(--orange); +} + +/* Roadmap */ +.roadmap-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + margin-top: 48px; +} + +.roadmap-card { + background: var(--code-bg); + border: 1px solid #222; + border-radius: 12px; + padding: 32px; +} + +.roadmap-card h3 { + font-size: 18px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 10px; +} + +.roadmap-card p { + color: var(--gray); + font-size: 15px; +} + +.roadmap-tag { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + margin-bottom: 12px; + display: inline-block; +} + +.roadmap-tag.shipped { + background: #2a1a1a; + color: var(--orange); +} + +.roadmap-tag.now { + background: #1a2a1a; + color: #4ade80; +} + +.roadmap-tag.next { + background: #2a2a1a; + color: #facc15; +} + +.roadmap-tag.later { + background: #1a1a2a; + color: #60a5fa; +} + +/* Manifesto */ +.manifesto { + background: linear-gradient(135deg, #0f0f0f 0%, #141414 100%); + border: 1px solid #222; + border-radius: 16px; + padding: 48px; + margin-top: 48px; +} + +.manifesto blockquote { + font-size: clamp(18px, 3vw, 24px); + font-style: italic; + color: var(--light); + border-left: 3px solid var(--orange); + padding-left: 24px; + margin: 0; +} + +/* Footer */ +footer { + padding: 48px 0; + border-top: 1px solid #1a1a1a; + text-align: center; +} + +footer p { + color: var(--gray); + font-size: 14px; +} + +footer a { + color: var(--orange); + text-decoration: none; +} + +/* Responsive */ +@media (max-width: 600px) { + .hero { + padding: 60px 0; + min-height: auto; + } + + section { + padding: 80px 0; + } + + .flow-step { + grid-template-columns: 1fr; + gap: 16px; + } + + .manifesto { + padding: 32px 24px; + } + + .auth-pillars { + grid-template-columns: 1fr; + } + + .nav-links { + gap: 20px; + } +} diff --git a/landing-page/why.html b/landing-page/why.html new file mode 100644 index 0000000..d23f715 --- /dev/null +++ b/landing-page/why.html @@ -0,0 +1,134 @@ + + + + + + Why Bitcoin for Agents — Claw Cash + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+

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

+ +
+
+ + + + diff --git a/package.json b/package.json index 417dd64..434e489 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,17 @@ "version": "0.1.0", "description": "Claw Cash MVP for Evervault Enclaves", "scripts": { - "typecheck": "pnpm -r --filter ./api --filter ./enclave typecheck", + "typecheck": "pnpm -r --filter ./api --filter ./enclave --filter ./sdk --filter ./skills --filter ./cli --filter ./web typecheck", + "build:web": "pnpm --filter @clw-cash/web build", + "dev:web": "pnpm --filter @clw-cash/web dev", "start:api": "pnpm --filter ./api start", - "start:enclave": "pnpm --filter ./enclave start" + "start:enclave": "pnpm --filter ./enclave start", + "test": "vitest run", + "test:e2e": "vitest run test/e2e.test.ts", + "deploy": "./scripts/deploy.sh" }, - "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a" + "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a", + "devDependencies": { + "vitest": "^4.0.18" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6465c72..96a308e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,32 +6,79 @@ settings: importers: - .: {} + .: + devDependencies: + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.11)(tsx@4.21.0) api: dependencies: - express: - specifier: ^4.21.2 - version: 4.22.1 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - uuid: - specifier: ^11.1.0 - version: 11.1.0 + hono: + specifier: ^4.7.0 + version: 4.11.9 + jose: + specifier: ^6.0.0 + version: 6.1.3 zod: specifier: ^3.24.2 version: 3.25.76 devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jsonwebtoken': - specifier: ^9.0.7 - version: 9.0.10 + '@cloudflare/workers-types': + specifier: ^4.20250214.0 + version: 4.20260214.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + wrangler: + specifier: ^4.65.0 + version: 4.65.0(@cloudflare/workers-types@4.20260214.0) + + cli: + dependencies: + '@arkade-os/boltz-swap': + specifier: ^0.2.18 + version: 0.2.20 + '@arkade-os/sdk': + specifier: ^0.3.12 + version: 0.3.13 + '@lendasat/lendaswap-sdk-pure': + specifier: ^0.0.2 + version: 0.0.2 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 + '@x402/evm': + specifier: ^2.3.1 + version: 2.3.1(typescript@5.9.3) + '@x402/fetch': + specifier: ^2.3.0 + version: 2.3.0(typescript@5.9.3) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 + minimist: + specifier: ^1.2.8 + version: 1.2.8 + viem: + specifier: ^2.45.3 + version: 2.45.3(typescript@5.9.3)(zod@4.3.6) + devDependencies: + '@clw-cash/sdk': + specifier: workspace:* + version: link:../remote-signer-identity + '@clw-cash/skills': + specifier: workspace:* + version: link:../skills + '@types/minimist': + specifier: ^1.2.5 + version: 1.2.5 '@types/node': specifier: ^22.13.4 version: 22.19.11 + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.19.3 version: 4.21.0 @@ -41,18 +88,15 @@ importers: enclave: dependencies: - '@noble/hashes': - specifier: 2.0.1 - version: 2.0.1 '@noble/secp256k1': specifier: 3.0.0 version: 3.0.0 express: specifier: 5.2.1 version: 5.2.1 - jsonwebtoken: - specifier: 9.0.3 - version: 9.0.3 + jose: + specifier: ^6.1.3 + version: 6.1.3 zod: specifier: 4.3.6 version: 4.3.6 @@ -60,9 +104,6 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.25 - '@types/jsonwebtoken': - specifier: ^9.0.7 - version: 9.0.10 '@types/node': specifier: ^22.13.4 version: 22.19.11 @@ -73,238 +114,948 @@ importers: specifier: ^5.7.3 version: 5.9.3 + remote-signer-identity: + dependencies: + '@arkade-os/sdk': + specifier: ^0.3.12 + version: 0.3.13 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 + '@noble/secp256k1': + specifier: 3.0.0 + version: 3.0.0 + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.19.11 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + skills: + dependencies: + '@arkade-os/boltz-swap': + specifier: ^0.2.18 + version: 0.2.20 + '@arkade-os/sdk': + specifier: ^0.3.12 + version: 0.3.13 + '@clw-cash/sdk': + specifier: workspace:* + version: link:../remote-signer-identity + '@lendasat/lendaswap-sdk-pure': + specifier: ^0.0.2 + version: 0.0.2 + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.19.11 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + web: + dependencies: + '@lendasat/lendaswap-sdk-pure': + specifier: ^0.0.2 + version: 0.0.2 + viem: + specifier: ^2.0.0 + version: 2.45.3(typescript@5.9.3)(zod@4.3.6) + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(@types/node@22.19.11)(tsx@4.21.0) + packages: + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@arkade-os/boltz-swap@0.2.20': + resolution: {integrity: sha512-1aQ9TKKEoW3CZXwlmM+CbjEpiyDshqYEY8HBeCijUl4drQW16dxDKtXMwn7pYJdWwdBEkhBFsF3MN/J0ctTCxg==} + engines: {node: '>=22'} + + '@arkade-os/sdk@0.3.13': + resolution: {integrity: sha512-eC6XGifqVf2Xu+sf/3T2duUgLiXlgh/IQXHD+v09wsu8ik/CZedH4gvQpetTjRMKti3DdY/QVLC1OQTKKWgvqQ==} + engines: {node: '>=20.0.0'} + + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} + engines: {node: '>=18.0.0'} + + '@cloudflare/unenv-preset@2.12.1': + resolution: {integrity: sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: ^1.20260115.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260212.0': + resolution: {integrity: sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260212.0': + resolution: {integrity: sha512-fqoqQWMA1D0ZzDOD8sp0allREM2M8GHdpxMXQ8EdZpZ70z5bJbJ9Vr4qe35++FNIZJspsDHfTw3Xm/M4ELm/dQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260212.0': + resolution: {integrity: sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260212.0': + resolution: {integrity: sha512-GPvp1iiKQodtbUDi6OmR5I0vD75lawB54tdYGtmypuHC7ZOI2WhBmhb3wCxgnQNOG1z7mhCQrzRCoqrKwYbVWQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260212.0': + resolution: {integrity: sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260214.0': + resolution: {integrity: sha512-qb8rgbAdJR4BAPXolXhFL/wuGtecHLh1veOyZ1mK6QqWuCdI3vK1biKC0i3lzmzdLR/DZvsN3mNtpUE8zpWGEg==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@noble/hashes@2.0.1': - resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} - engines: {node: '>= 20.19.0'} - - '@noble/secp256k1@3.0.0': - resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] - '@types/node@22.19.11': - resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lendasat/lendaswap-sdk-pure@0.0.2': + resolution: {integrity: sha512-4N9ugC/5ja7p5U0pRfRhopxu7Nld5u94117kklucmjDd8zdLd4BUdIVXObLhwgze+/WvAaU6jCp/S3Maks54Lw==} + + '@marcbachmann/cel-js@7.3.1': + resolution: {integrity: sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ==} + engines: {node: '>=20.19.0'} + hasBin: true + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@2.0.0': + resolution: {integrity: sha512-RiwZZeJnsTnhT+/gg2KvITJZhK5oagQrpZo+yQyd3mv3D5NAG2qEeEHpw7IkXRlpkoD45wl2o4ydHAvY9wyEfw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.0.0': + resolution: {integrity: sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + + '@noble/secp256k1@3.0.0': + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@scure/base@1.1.1': + resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@scure/btc-signer@2.0.1': + resolution: {integrity: sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.14': + resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + '@x402/core@2.3.1': + resolution: {integrity: sha512-CWvsf09tslISoVOzQ2TIoBLBP+bUycPsYmmRVe3EV5X2FtD7eXXpiPsiXLEVtWP7zhqLNP/5OIATsA2hSVLSfw==} + + '@x402/evm@2.3.1': + resolution: {integrity: sha512-aN40OHG0HN1PhBXNFMRi5ISLHsExpF4qIvWUoVMKhFCfaXQDrqsfOKOC3PfcFtK/2Q8/SoiXNN7LsCkChL62lw==} + + '@x402/fetch@2.3.0': + resolution: {integrity: sha512-XyY5wcR1gClC5QXJ4ev8O95ZHpiF8UMh5KkMHPYlNqxi59WNXR563XZE8LVoBd/fgRhDdjAoXlqwqBuDL50HOw==} + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bip68@1.0.4: + resolution: {integrity: sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==} + engines: {node: '>=4.5.0'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -314,9 +1065,27 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} @@ -326,9 +1095,6 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -337,13 +1103,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -354,21 +1116,29 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dexie@4.3.0: + resolution: {integrity: sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -376,6 +1146,12 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -384,10 +1160,18 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -396,38 +1180,58 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -447,6 +1251,9 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -459,21 +1266,27 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.11.9: + resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} + engines: {node: '>=16.9.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -481,336 +1294,1106 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + light-bolt11-decoder@3.2.0: + resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==} - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} + micro-packed@0.8.0: + resolution: {integrity: sha512-AKb8znIvg9sooythbXzyFeChEY0SkW0C6iXECpy/ls0e5BtwXO45J9wD9SLzBztnS4XmF/5kwZknsq+jyynd/A==} + engines: {node: '>= 20.19.0'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + miniflare@4.20260212.0: + resolution: {integrity: sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg==} + engines: {node: '>=18.0.0'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi-fetch@0.15.2: + resolution: {integrity: sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + ox@0.12.1: + resolution: {integrity: sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.18.2: + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + viem@2.45.3: + resolution: {integrity: sha512-axOD7rIbGiDHHA1MHKmpqqTz3CMCw7YpE/FVypddQMXL5i364VkNZh9JeEJH17NO484LaZUOMueo35IXyL76Mw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260212.0: + resolution: {integrity: sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.65.0: + resolution: {integrity: sha512-R+n3o3tlGzLK9I4fGocPReOuvcnjhtOL2aCVKkHMeuEwt9pPbOO4FxJtx/ec5cIUG/otRyJnfQGCAr9DplBVng==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260212.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@arkade-os/boltz-swap@0.2.20': + dependencies: + '@arkade-os/sdk': 0.3.13 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/btc-signer': 2.0.1 + bip68: 1.0.4 + light-bolt11-decoder: 3.2.0 + + '@arkade-os/sdk@0.3.13': + dependencies: + '@marcbachmann/cel-js': 7.3.1 + '@noble/curves': 2.0.0 + '@noble/secp256k1': 3.0.0 + '@scure/base': 2.0.0 + '@scure/btc-signer': 2.0.1 + bip68: 1.0.4 + + '@cloudflare/kv-asset-handler@0.4.2': {} + + '@cloudflare/unenv-preset@2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260212.0 + + '@cloudflare/workerd-darwin-64@1.20260212.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260212.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20260212.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260212.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20260212.0': + optional: true + + '@cloudflare/workers-types@4.20260214.0': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} + '@esbuild/sunos-x64@0.27.3': + optional: true - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} + '@esbuild/win32-arm64@0.25.12': + optional: true - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} + '@esbuild/win32-arm64@0.27.3': + optional: true - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} + '@esbuild/win32-ia32@0.25.12': + optional: true - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true + '@esbuild/win32-ia32@0.27.3': + optional: true - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + '@esbuild/win32-x64@0.25.12': + optional: true - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + '@esbuild/win32-x64@0.27.3': + optional: true - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} + '@img/colour@1.0.0': {} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} - engines: {node: '>=0.6'} + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + '@img/sharp-win32-arm64@0.34.5': + optional: true - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} + '@img/sharp-win32-ia32@0.34.5': + optional: true - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} + '@img/sharp-win32-x64@0.34.5': + optional: true - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} + '@jridgewell/resolve-uri@3.1.2': {} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} + '@jridgewell/sourcemap-codec@1.5.5': {} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} + '@lendasat/lendaswap-sdk-pure@0.0.2': + dependencies: + '@arkade-os/sdk': 0.3.13 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + '@scure/btc-signer': 2.0.1 + dexie: 4.3.0 + openapi-fetch: 0.15.2 + optionalDependencies: + better-sqlite3: 12.6.2 - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + '@marcbachmann/cel-js@7.3.1': {} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true + '@noble/ciphers@1.3.0': {} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} + '@noble/curves@2.0.0': + dependencies: + '@noble/hashes': 2.0.0 - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true + '@noble/hashes@1.8.0': {} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} + '@noble/hashes@2.0.0': {} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + '@noble/hashes@2.0.1': {} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + '@noble/secp256k1@3.0.0': {} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 -snapshots: + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 - '@esbuild/aix-ppc64@0.27.3': - optional: true + '@poppinss/exception@1.2.3': {} - '@esbuild/android-arm64@0.27.3': + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true - '@esbuild/android-arm@0.27.3': + '@rollup/rollup-android-arm64@4.57.1': optional: true - '@esbuild/android-x64@0.27.3': + '@rollup/rollup-darwin-arm64@4.57.1': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@rollup/rollup-darwin-x64@4.57.1': optional: true - '@esbuild/darwin-x64@0.27.3': + '@rollup/rollup-freebsd-arm64@4.57.1': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@rollup/rollup-freebsd-x64@4.57.1': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': optional: true - '@esbuild/linux-arm64@0.27.3': + '@rollup/rollup-linux-arm-musleabihf@4.57.1': optional: true - '@esbuild/linux-arm@0.27.3': + '@rollup/rollup-linux-arm64-gnu@4.57.1': optional: true - '@esbuild/linux-ia32@0.27.3': + '@rollup/rollup-linux-arm64-musl@4.57.1': optional: true - '@esbuild/linux-loong64@0.27.3': + '@rollup/rollup-linux-loong64-gnu@4.57.1': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@rollup/rollup-linux-loong64-musl@4.57.1': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@rollup/rollup-linux-ppc64-gnu@4.57.1': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@rollup/rollup-linux-ppc64-musl@4.57.1': optional: true - '@esbuild/linux-s390x@0.27.3': + '@rollup/rollup-linux-riscv64-gnu@4.57.1': optional: true - '@esbuild/linux-x64@0.27.3': + '@rollup/rollup-linux-riscv64-musl@4.57.1': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@rollup/rollup-linux-s390x-gnu@4.57.1': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@rollup/rollup-linux-x64-gnu@4.57.1': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@rollup/rollup-linux-x64-musl@4.57.1': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@rollup/rollup-openbsd-x64@4.57.1': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@rollup/rollup-openharmony-arm64@4.57.1': optional: true - '@esbuild/sunos-x64@0.27.3': + '@rollup/rollup-win32-arm64-msvc@4.57.1': optional: true - '@esbuild/win32-arm64@0.27.3': + '@rollup/rollup-win32-ia32-msvc@4.57.1': optional: true - '@esbuild/win32-ia32@0.27.3': + '@rollup/rollup-win32-x64-gnu@4.57.1': optional: true - '@esbuild/win32-x64@0.27.3': + '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@noble/hashes@2.0.1': {} + '@scure/base@1.1.1': {} - '@noble/secp256k1@3.0.0': {} + '@scure/base@1.2.6': {} + + '@scure/base@2.0.0': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/btc-signer@2.0.1': + dependencies: + '@noble/curves': 2.0.0 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + micro-packed: 0.8.0 + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.14': {} + + '@standard-schema/spec@1.1.0': {} '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 '@types/node': 22.19.11 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.11 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.8': dependencies: '@types/node': 22.19.11 @@ -827,14 +2410,9 @@ snapshots: '@types/http-errors@2.0.5': {} - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 22.19.11 - '@types/mime@1.3.5': {} - '@types/ms@2.1.0': {} + '@types/minimist@1.2.5': {} '@types/node@22.19.11': dependencies: @@ -859,34 +2437,110 @@ snapshots: '@types/node': 22.19.11 '@types/send': 0.17.6 - accepts@1.3.8: + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(tsx@4.21.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + '@x402/core@2.3.1': + dependencies: + zod: 3.25.76 + + '@x402/evm@2.3.1(typescript@5.9.3)': + dependencies: + '@x402/core': 2.3.1 + viem: 2.45.3(typescript@5.9.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@x402/fetch@2.3.0(typescript@5.9.3)': dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + '@x402/core': 2.3.1 + viem: 2.45.3(typescript@5.9.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + + abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): + optionalDependencies: + typescript: 5.9.3 + zod: 4.3.6 accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 - array-flatten@1.1.1: {} + acorn@8.15.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} - body-parser@1.20.4: + base64-js@1.5.1: {} + + better-sqlite3@12.6.2: dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bip68@1.0.4: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + blake3-wasm@2.1.5: {} body-parser@2.2.2: dependencies: @@ -902,10 +2556,20 @@ snapshots: transitivePeerDependencies: - supports-color - buffer-equal-constant-time@1.0.1: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-require@5.1.0(esbuild@0.27.3): + dependencies: + esbuild: 0.27.3 + load-tsconfig: 0.2.5 bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -916,31 +2580,45 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - content-disposition@0.5.4: + chai@6.2.2: {} + + chokidar@4.0.3: dependencies: - safe-buffer: 5.2.1 + readdirp: 4.1.2 + + chownr@1.1.4: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} content-disposition@1.0.1: {} content-type@1.0.5: {} - cookie-signature@1.0.7: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 + cookie@1.1.1: {} debug@4.4.3: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + depd@2.0.0: {} - destroy@1.2.0: {} + detect-libc@2.1.2: {} + + dexie@4.3.0: {} dunder-proto@1.0.1: dependencies: @@ -948,22 +2626,55 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - ee-first@1.1.1: {} encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -995,43 +2706,17 @@ snapshots: escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color + eventemitter3@5.0.1: {} + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} express@5.2.1: dependencies: @@ -1066,17 +2751,11 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-uri-to-path@1.0.0: {} finalhandler@2.1.1: dependencies: @@ -1089,12 +2768,18 @@ snapshots: transitivePeerDependencies: - supports-color - forwarded@0.2.0: {} + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.0 + rollup: 4.57.1 - fresh@0.5.2: {} + forwarded@0.2.0: {} fresh@2.0.0: {} + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true @@ -1122,6 +2807,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -1130,6 +2817,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.11.9: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -1138,94 +2827,109 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + inherits@2.0.4: {} + ini@1.3.8: {} + ipaddr.js@1.9.1: {} is-promise@4.0.0: {} - jsonwebtoken@9.0.3: - dependencies: - jws: 4.0.1 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.4 - - jwa@2.0.1: + isows@1.0.7(ws@8.18.3): dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 + ws: 8.18.3 - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 + jose@6.1.3: {} - lodash.includes@4.3.0: {} + joycon@3.1.1: {} - lodash.isboolean@3.0.3: {} + kleur@4.1.5: {} - lodash.isinteger@4.0.4: {} + light-bolt11-decoder@3.2.0: + dependencies: + '@scure/base': 1.1.1 - lodash.isnumber@3.0.3: {} + lilconfig@3.1.3: {} - lodash.isplainobject@4.0.6: {} + lines-and-columns@1.2.4: {} - lodash.isstring@4.0.1: {} + load-tsconfig@0.2.5: {} - lodash.once@4.1.1: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} - media-typer@1.1.0: {} - merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} - methods@1.1.2: {} - - mime-db@1.52.0: {} + micro-packed@0.8.0: + dependencies: + '@scure/base': 2.0.0 mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} + mimic-response@3.1.0: {} - ms@2.0.0: {} + miniflare@4.20260212.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.18.2 + workerd: 1.20260212.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 ms@2.1.3: {} - negotiator@0.6.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + napi-build-utils@2.0.0: {} negotiator@1.0.0: {} + node-abi@3.87.0: + dependencies: + semver: 7.7.4 + + object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -1234,30 +2938,106 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-fetch@0.15.2: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + ox@0.12.1(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.12.1(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + parseurl@1.3.3: {} - path-to-regexp@0.1.12: {} + path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.6 + tsx: 4.21.0 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + qs@6.14.2: dependencies: side-channel: 1.1.0 range-parser@1.2.1: {} - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -1265,8 +3045,56 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -1283,24 +3111,6 @@ snapshots: semver@7.7.4: {} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - send@1.2.1: dependencies: debug: 4.4.3 @@ -1317,15 +3127,6 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -1337,6 +3138,37 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -1365,10 +3197,117 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-color@10.2.2: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.3) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.3 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0) + resolve-from: 5.0.0 + rollup: 4.57.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.21.0: dependencies: esbuild: 0.27.3 @@ -1376,10 +3315,9 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - type-is@1.6.18: + tunnel-agent@0.6.0: dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 + safe-buffer: 5.2.1 type-is@2.0.1: dependencies: @@ -1389,18 +3327,168 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} - unpipe@1.0.0: {} + undici@7.18.2: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 - utils-merge@1.0.1: {} + unpipe@1.0.0: {} - uuid@11.1.0: {} + util-deprecate@1.0.2: {} vary@1.1.2: {} + viem@2.45.3(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3) + ox: 0.12.1(typescript@5.9.3)(zod@3.25.76) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.45.3(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3) + ox: 0.12.1(typescript@5.9.3)(zod@4.3.6) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + vite@6.4.1(@types/node@22.19.11)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + tsx: 4.21.0 + + vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.0.18(@types/node@22.19.11)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.11)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.11 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260212.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260212.0 + '@cloudflare/workerd-darwin-arm64': 1.20260212.0 + '@cloudflare/workerd-linux-64': 1.20260212.0 + '@cloudflare/workerd-linux-arm64': 1.20260212.0 + '@cloudflare/workerd-windows-64': 1.20260212.0 + + wrangler@4.65.0(@cloudflare/workers-types@4.20260214.0): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260212.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260212.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260214.0 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrappy@1.0.2: {} + ws@8.18.0: {} + + ws@8.18.3: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.14 + cookie: 1.1.1 + youch-core: 0.3.3 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d6afb38..b624425 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ packages: - "api" - "enclave" + - "remote-signer-identity" + - "skills" + - "cli" + - "web" diff --git a/remote-signer-identity/package.json b/remote-signer-identity/package.json new file mode 100644 index 0000000..680f6ea --- /dev/null +++ b/remote-signer-identity/package.json @@ -0,0 +1,21 @@ +{ + "name": "@clw-cash/sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@arkade-os/sdk": "^0.3.12", + "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "3.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "typescript": "^5.7.3" + } +} diff --git a/remote-signer-identity/src/apiClient.ts b/remote-signer-identity/src/apiClient.ts new file mode 100644 index 0000000..1008fa1 --- /dev/null +++ b/remote-signer-identity/src/apiClient.ts @@ -0,0 +1,136 @@ +import type { + CreateIdentityResponse, + EcdsaSignResponse, + ListIdentitiesResponse, + SignBatchResponse, + SignIntentResponse, + SignResponse, +} from "./types.js"; + +export class ClwApiError extends Error { + constructor( + public readonly statusCode: number, + message: string + ) { + super(message); + } +} + +export class ClwApiClient { + constructor( + private readonly baseUrl: string, + private readonly identityId: string, + private sessionToken: string + ) {} + + /** Update the session token (e.g., after refresh) */ + setSessionToken(token: string): void { + this.sessionToken = token; + } + + /** Create a new identity on the server */ + static async createIdentity( + baseUrl: string, + sessionToken: string + ): Promise { + const res = await fetch(`${baseUrl}/v1/identities`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify({ alg: "secp256k1" }), + }); + return ClwApiClient.handleResponse(res); + } + + /** List all active identities for the authenticated user */ + static async listIdentities( + baseUrl: string, + sessionToken: string + ): Promise { + const res = await fetch(`${baseUrl}/v1/identities`, { + headers: { authorization: `Bearer ${sessionToken}` }, + }); + return ClwApiClient.handleResponse(res); + } + + /** Sign a single digest via the two-step flow (sign-intent → sign) */ + async signDigest(digestHex: string): Promise { + const intent = await this.signIntent(digestHex); + const signed = await this.sign(digestHex, intent.ticket); + return signed.signature; + } + + /** Sign a single digest with ECDSA, returning r, s, v */ + async signDigestEcdsa(digestHex: string): Promise { + const intent = await this.signIntent(digestHex, "ecdsa"); + const signed = await this.sign(digestHex, intent.ticket, "ecdsa"); + return { + signature: signed.signature, + r: signed.r!, + s: signed.s!, + v: signed.v!, + }; + } + + /** Sign multiple digests in a single batch call */ + async signDigestBatch( + digests: Array<{ digest: string }> + ): Promise { + const res = await this.request( + `/v1/identities/${this.identityId}/sign-batch`, + { + digests: digests.map((d) => ({ digest: d.digest })), + } + ); + const body = await ClwApiClient.handleResponse(res); + return body.signatures.map((s) => typeof s === "string" ? s : s.signature); + } + + private async signIntent(digestHex: string, signatureType: "schnorr" | "ecdsa" = "schnorr"): Promise { + const res = await this.request( + `/v1/identities/${this.identityId}/sign-intent`, + { digest: digestHex, signature_type: signatureType } + ); + return ClwApiClient.handleResponse(res); + } + + private async sign( + digestHex: string, + ticket: string, + signatureType: "schnorr" | "ecdsa" = "schnorr" + ): Promise { + const res = await this.request( + `/v1/identities/${this.identityId}/sign`, + { digest: digestHex, ticket, signature_type: signatureType } + ); + return ClwApiClient.handleResponse(res); + } + + private async request(path: string, body: unknown): Promise { + return fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.sessionToken}`, + }, + body: JSON.stringify(body), + }); + } + + private static async handleResponse(res: Response): Promise { + const text = await res.text(); + if (!res.ok) { + let message = "API error"; + try { + const parsed = JSON.parse(text) as { error?: string }; + message = parsed.error ?? text; + } catch { + message = text; + } + throw new ClwApiError(res.status, message); + } + return JSON.parse(text) as T; + } +} diff --git a/remote-signer-identity/src/evm.ts b/remote-signer-identity/src/evm.ts new file mode 100644 index 0000000..2fbd83f --- /dev/null +++ b/remote-signer-identity/src/evm.ts @@ -0,0 +1,15 @@ +import { Point } from "@noble/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { hexEncode } from "./hex.js"; + +/** + * Derive an EVM (Ethereum) address from a 33-byte compressed secp256k1 public key. + * Address = last 20 bytes of keccak256(uncompressed_pubkey_without_prefix). + */ +export function compressedPubKeyToEvmAddress(compressedPubKeyHex: string): string { + const point = Point.fromHex(compressedPubKeyHex); + const uncompressed = point.toBytes(false); // 65 bytes: 0x04 || x || y + const hash = keccak_256(uncompressed.slice(1)); // hash x || y (64 bytes) + const address = hexEncode(hash.slice(-20)); // last 20 bytes + return `0x${address}`; +} diff --git a/remote-signer-identity/src/hex.ts b/remote-signer-identity/src/hex.ts new file mode 100644 index 0000000..fcebc95 --- /dev/null +++ b/remote-signer-identity/src/hex.ts @@ -0,0 +1,16 @@ +/** Decode a hex string to Uint8Array */ +export function hexDecode(hexStr: string): Uint8Array { + const clean = hexStr.startsWith("0x") ? hexStr.slice(2) : hexStr; + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** Encode a Uint8Array to hex string */ +export function hexEncode(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/remote-signer-identity/src/index.ts b/remote-signer-identity/src/index.ts new file mode 100644 index 0000000..1770e0a --- /dev/null +++ b/remote-signer-identity/src/index.ts @@ -0,0 +1,15 @@ +export { RemoteSignerIdentity } from "./remoteSignerIdentity.js"; +export { ReadonlyRemoteIdentity } from "./readonlyRemoteIdentity.js"; +export { ClwApiClient, ClwApiError } from "./apiClient.js"; +export { extractDigests, injectSignatures } from "./signingUtils.js"; +export type { + RemoteSignerConfig, + InputDigest, + SignIntentResponse, + SignResponse, + EcdsaSignResponse, + SignBatchResponse, + CreateIdentityResponse, +} from "./types.js"; +export { hexDecode, hexEncode } from "./hex.js"; +export { compressedPubKeyToEvmAddress } from "./evm.js"; diff --git a/remote-signer-identity/src/readonlyRemoteIdentity.ts b/remote-signer-identity/src/readonlyRemoteIdentity.ts new file mode 100644 index 0000000..e3d096d --- /dev/null +++ b/remote-signer-identity/src/readonlyRemoteIdentity.ts @@ -0,0 +1,29 @@ +import { hexDecode } from "./hex.js"; +import type { ReadonlyIdentity } from "@arkade-os/sdk"; + +/** + * ReadonlyRemoteIdentity - a read-only identity that only holds the public key. + * Cannot sign anything, but can be used for watch-only wallets. + */ +export class ReadonlyRemoteIdentity implements ReadonlyIdentity { + private readonly pubKeyBytes: Uint8Array; + + constructor(compressedPublicKey: Uint8Array) { + if (compressedPublicKey.length !== 33) { + throw new Error("Expected 33-byte compressed public key"); + } + this.pubKeyBytes = compressedPublicKey; + } + + static fromHex(publicKeyHex: string): ReadonlyRemoteIdentity { + return new ReadonlyRemoteIdentity(hexDecode(publicKeyHex)); + } + + async xOnlyPublicKey(): Promise { + return this.pubKeyBytes.slice(1); + } + + async compressedPublicKey(): Promise { + return this.pubKeyBytes; + } +} diff --git a/remote-signer-identity/src/remoteSignerIdentity.ts b/remote-signer-identity/src/remoteSignerIdentity.ts new file mode 100644 index 0000000..d20c8d4 --- /dev/null +++ b/remote-signer-identity/src/remoteSignerIdentity.ts @@ -0,0 +1,215 @@ +import { hexDecode, hexEncode } from "./hex.js"; +import { Transaction } from "@arkade-os/sdk"; +import type { Identity, SignerSession } from "@arkade-os/sdk"; +import { ClwApiClient } from "./apiClient.js"; +import { ReadonlyRemoteIdentity } from "./readonlyRemoteIdentity.js"; +import { extractDigests, injectSignatures } from "./signingUtils.js"; +import { compressedPubKeyToEvmAddress } from "./evm.js"; +import type { EcdsaSignResponse, RemoteSignerConfig } from "./types.js"; + +// Dynamic import to avoid hard dependency on internal SDK module +let _TreeSignerSession: { random(): SignerSession } | null = null; +async function getTreeSignerSession(): Promise<{ random(): SignerSession }> { + if (!_TreeSignerSession) { + const mod = await import("@arkade-os/sdk"); + // TreeSignerSession is a named export from the SDK + _TreeSignerSession = (mod as Record).TreeSignerSession as { random(): SignerSession }; + } + return _TreeSignerSession; +} + +/** + * RemoteSignerIdentity implements the @arkade-os/sdk Identity interface + * by delegating all signing operations to the clw.cash API server. + * + * Instead of holding a private key locally, it holds only the public key + * and calls the remote API for each signature. + */ +export class RemoteSignerIdentity implements Identity { + private readonly apiClient: ClwApiClient; + private readonly pubKeyBytes: Uint8Array; + private readonly xOnlyBytes: Uint8Array; + + constructor(config: RemoteSignerConfig) { + this.pubKeyBytes = hexDecode(config.compressedPublicKey); + if (this.pubKeyBytes.length !== 33) { + throw new Error("Expected 33-byte compressed public key (66 hex chars)"); + } + this.xOnlyBytes = this.pubKeyBytes.slice(1); + this.apiClient = new ClwApiClient( + config.apiBaseUrl, + config.identityId, + config.sessionToken + ); + } + + /** + * Create a new identity on the server and return a configured RemoteSignerIdentity. + */ + static async create( + apiBaseUrl: string, + sessionToken: string + ): Promise { + const identity = await ClwApiClient.createIdentity(apiBaseUrl, sessionToken); + return new RemoteSignerIdentity({ + apiBaseUrl, + identityId: identity.id, + sessionToken, + compressedPublicKey: identity.public_key, + }); + } + + // ── ReadonlyIdentity ────────────────────────────────────── + + async xOnlyPublicKey(): Promise { + return this.xOnlyBytes; + } + + async compressedPublicKey(): Promise { + return this.pubKeyBytes; + } + + // ── Identity ────────────────────────────────────────────── + + signerSession(): SignerSession { + // Use dynamic import result; if not yet loaded, do a sync fallback + if (!_TreeSignerSession) { + // Trigger async load for future calls + void getTreeSignerSession(); + // For now, return a minimal session that will be initialized before use + throw new Error("TreeSignerSession not yet loaded. Call await RemoteSignerIdentity.init() first or use signerSessionAsync()."); + } + return _TreeSignerSession.random(); + } + + /** + * Async version of signerSession() that ensures the module is loaded. + */ + async signerSessionAsync(): Promise { + const TSS = await getTreeSignerSession(); + return TSS.random(); + } + + /** + * Pre-load async dependencies. Call this once after construction if you need signerSession(). + */ + static async init(): Promise { + await getTreeSignerSession(); + } + + /** + * Sign a raw message by delegating to the remote API. + * For "schnorr": returns 64-byte Schnorr signature. + * For "ecdsa": returns 65-byte r||s||v signature. + */ + async signMessage( + message: Uint8Array, + signatureType: "schnorr" | "ecdsa" + ): Promise { + const digestHex = hexEncode(message); + if (signatureType === "ecdsa") { + const result = await this.apiClient.signDigestEcdsa(digestHex); + // Return 65 bytes: r (32) || s (32) || v (1) + const r = hexDecode(result.r); + const s = hexDecode(result.s); + const sig = new Uint8Array(65); + sig.set(r, 0); + sig.set(s, 32); + sig[64] = result.v; + return sig; + } + const sigHex = await this.apiClient.signDigest(digestHex); + return hexDecode(sigHex); + } + + /** + * Sign a digest with ECDSA via the remote API. + * Returns { r, s, v } suitable for EVM transaction signing. + */ + async signEcdsaDigest(digestHex: string): Promise { + return this.apiClient.signDigestEcdsa(digestHex); + } + + /** + * Derive the EVM address from this identity's compressed public key. + */ + getEvmAddress(): string { + return compressedPubKeyToEvmAddress(hexEncode(this.pubKeyBytes)); + } + + /** + * Sign a PSBT transaction by extracting sighash digests, sending them + * to the remote API for signing, and injecting the signatures back. + */ + async sign(tx: Transaction, inputIndexes?: number[]): Promise { + const digests = extractDigests(tx, this.xOnlyBytes, inputIndexes); + if (digests.length === 0) { + return tx; + } + + const digestHexes = digests.map((d) => ({ + digest: hexEncode(d.digest), + })); + const signatureHexes = await this.apiClient.signDigestBatch(digestHexes); + + const results = digests.map((digest, i) => ({ + digest, + signature: hexDecode(signatureHexes[i]), + })); + + return injectSignatures(tx, results); + } + + /** + * Sign multiple transactions in a single batch (Xverse-like pattern). + * All transactions are signed atomically — all or nothing. + */ + async signMultipleTransactions(txs: Transaction[]): Promise { + const allDigests: Array<{ + txIndex: number; + digest: ReturnType[number]; + }> = []; + + for (let txIdx = 0; txIdx < txs.length; txIdx++) { + const digests = extractDigests(txs[txIdx], this.xOnlyBytes); + for (const digest of digests) { + allDigests.push({ txIndex: txIdx, digest }); + } + } + + if (allDigests.length === 0) { + return txs; + } + + const digestHexes = allDigests.map((d) => ({ + digest: hexEncode(d.digest.digest), + })); + const signatureHexes = await this.apiClient.signDigestBatch(digestHexes); + + const byTx = new Map>(); + for (let i = 0; i < allDigests.length; i++) { + const { txIndex, digest } = allDigests[i]; + if (!byTx.has(txIndex)) byTx.set(txIndex, []); + byTx.get(txIndex)!.push({ + digest, + signature: hexDecode(signatureHexes[i]), + }); + } + + for (const [txIndex, results] of byTx) { + injectSignatures(txs[txIndex], results); + } + + return txs; + } + + /** Convert to a readonly identity (strips signing capability) */ + toReadonly(): ReadonlyRemoteIdentity { + return new ReadonlyRemoteIdentity(this.pubKeyBytes); + } + + /** Update the session token */ + setSessionToken(token: string): void { + this.apiClient.setSessionToken(token); + } +} diff --git a/remote-signer-identity/src/signingUtils.ts b/remote-signer-identity/src/signingUtils.ts new file mode 100644 index 0000000..5f7ff42 --- /dev/null +++ b/remote-signer-identity/src/signingUtils.ts @@ -0,0 +1,226 @@ +import { Transaction } from "@arkade-os/sdk"; +import { sha256 } from "@noble/hashes/sha2.js"; +import type { InputDigest } from "./types.js"; + +/** + * BIP 340 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || msg) + */ +function taggedHash(tag: string, ...msgs: Uint8Array[]): Uint8Array { + const tagHash = sha256(new TextEncoder().encode(tag)); + let totalLen = tagHash.length * 2; + for (const m of msgs) totalLen += m.length; + const buf = new Uint8Array(totalLen); + buf.set(tagHash, 0); + buf.set(tagHash, tagHash.length); + let offset = tagHash.length * 2; + for (const m of msgs) { + buf.set(m, offset); + offset += m.length; + } + return sha256(buf); +} + +/** + * Bitcoin CompactSize (varint) encoding for script length prefix. + */ +function compactSize(n: number): Uint8Array { + if (n < 0xfd) return new Uint8Array([n]); + if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]); + if (n <= 0xffffffff) + return new Uint8Array([ + 0xfe, + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >> 24) & 0xff, + ]); + throw new Error("compactSize: value too large"); +} + +/** + * Compute BIP 341 tap leaf hash: taggedHash("TapLeaf", version || compactSize(len) || script) + */ +function tapLeafHash( + script: Uint8Array, + version: number = 0xc0 +): Uint8Array { + return taggedHash( + "TapLeaf", + new Uint8Array([version]), + compactSize(script.length), + script + ); +} + +/** + * Get previous output (script + amount) from a PSBT input. + * Mirrors @scure/btc-signer's getPrevOut(). + */ +function getPrevOut(input: ReturnType): { + script: Uint8Array; + amount: bigint; +} { + if (input.nonWitnessUtxo && input.index !== undefined) { + return input.nonWitnessUtxo.outputs[input.index]; + } + if (input.witnessUtxo) return input.witnessUtxo; + throw new Error("Cannot find previous output info"); +} + +/** + * Extract sighash digests from a PSBT for all taproot inputs matching our pubkey. + * + * This replicates the digest-computation part of Transaction.signIdx() without + * the actual signing step, so we can delegate signing to a remote server. + */ +export function extractDigests( + tx: Transaction, + xOnlyPubKey: Uint8Array, + inputIndexes?: number[] +): InputDigest[] { + const digests: InputDigest[] = []; + const indexes = + inputIndexes ?? Array.from({ length: tx.inputsLength }, (_, i) => i); + + // Collect prevOutScript and amount arrays for ALL inputs (required by BIP 341) + const prevOutScripts: Uint8Array[] = []; + const amounts: bigint[] = []; + for (let i = 0; i < tx.inputsLength; i++) { + const inp = tx.getInput(i); + try { + const prevOut = getPrevOut(inp); + prevOutScripts.push(prevOut.script); + amounts.push(prevOut.amount); + } catch { + // Input without prevout info — push placeholders + prevOutScripts.push(new Uint8Array(0)); + amounts.push(0n); + } + } + + for (const idx of indexes) { + const input = tx.getInput(idx); + if (!input) continue; + + const sighash = input.sighashType ?? 0x00; // DEFAULT for taproot + + // Taproot key-path: tapInternalKey matches our x-only pubkey + if (input.tapInternalKey && bytesEqual(input.tapInternalKey, xOnlyPubKey)) { + try { + const preimage = tx.preimageWitnessV1( + idx, + prevOutScripts, + sighash, + amounts + ); + digests.push({ + inputIndex: idx, + digest: preimage, + signatureType: "schnorr", + isTapKeyPath: true, + }); + continue; + } catch { + // Could not compute preimage for this input, skip + } + } + + // Taproot leaf-script: tapLeafScript contains our pubkey + if (input.tapLeafScript) { + for (const [, leafBytes] of input.tapLeafScript) { + // leafBytes is raw Uint8Array: script bytes + version byte appended at end + const script = leafBytes.subarray(0, -1); + const ver = leafBytes[leafBytes.length - 1]; + + if (script.length > 0 && containsPubKey(script, xOnlyPubKey)) { + try { + const preimage = tx.preimageWitnessV1( + idx, + prevOutScripts, + sighash, + amounts, + undefined, // codeSeparator + script, // actual leaf script bytes + ver // leaf version + ); + const leafHash = tapLeafHash(script, ver); + digests.push({ + inputIndex: idx, + digest: preimage, + signatureType: "schnorr", + isTapKeyPath: false, + leafHash, + signerPubKey: xOnlyPubKey, + }); + } catch { + // Could not compute preimage for this leaf, skip + } + } + } + } + } + + return digests; +} + +/** + * Inject Schnorr signatures back into a Transaction's PSBT inputs. + */ +export function injectSignatures( + tx: Transaction, + results: Array<{ digest: InputDigest; signature: Uint8Array }> +): Transaction { + for (const { digest, signature } of results) { + const idx = digest.inputIndex; + + if (digest.isTapKeyPath) { + const sighash = tx.getInput(idx)?.sighashType ?? 0x00; + const sig = + sighash === 0x00 + ? signature + : Uint8Array.from([...signature, sighash]); + tx.updateInput(idx, { tapKeySig: sig }); + } else if (digest.leafHash) { + const input = tx.getInput(idx); + const sighash = input?.sighashType ?? 0x00; + const sig = + sighash === 0x00 + ? signature + : Uint8Array.from([...signature, sighash]); + + const pubkey = digest.signerPubKey; + if (pubkey) { + tx.updateInput(idx, { + tapScriptSig: [ + [{ pubKey: pubkey, leafHash: digest.leafHash }, sig], + ], + }); + } + } + } + + return tx; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function containsPubKey(script: Uint8Array, pubKey: Uint8Array): boolean { + if (script.length < pubKey.length) return false; + for (let i = 0; i <= script.length - pubKey.length; i++) { + let match = true; + for (let j = 0; j < pubKey.length; j++) { + if (script[i + j] !== pubKey[j]) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} diff --git a/remote-signer-identity/src/types.ts b/remote-signer-identity/src/types.ts new file mode 100644 index 0000000..f34e47a --- /dev/null +++ b/remote-signer-identity/src/types.ts @@ -0,0 +1,60 @@ +export interface RemoteSignerConfig { + /** Base URL of the clw.cash API (e.g., "http://localhost:4000") */ + apiBaseUrl: string; + /** UUID of the identity on the server */ + identityId: string; + /** JWT session token from /v1/auth/verify */ + sessionToken: string; + /** Hex-encoded 33-byte compressed public key (from identity creation) */ + compressedPublicKey: string; +} + +export interface SignIntentResponse { + id: string; + identity_id: string; + digest_hash: string; + nonce: string; + scope: "sign"; + expires_at: string; + ticket: string; +} + +export interface SignResponse { + signature: string; + r?: string; + s?: string; + v?: number; +} + +export interface EcdsaSignResponse { + signature: string; + r: string; + s: string; + v: number; +} + +export interface SignBatchResponse { + signatures: Array<{ signature: string; r?: string; s?: string; v?: number }>; +} + +export interface CreateIdentityResponse { + id: string; + user_id: string; + alg: "secp256k1"; + public_key: string; + status: "active"; + created_at: string; +} + +export interface ListIdentitiesResponse { + items: CreateIdentityResponse[]; +} + +export interface InputDigest { + inputIndex: number; + digest: Uint8Array; + signatureType: "schnorr"; + isTapKeyPath: boolean; + leafHash?: Uint8Array; + signerPubKey?: Uint8Array; +} diff --git a/remote-signer-identity/tsconfig.json b/remote-signer-identity/tsconfig.json new file mode 100644 index 0000000..33d3769 --- /dev/null +++ b/remote-signer-identity/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"], + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/schemas/json/internal/backup-export.request.json b/schemas/json/internal/backup-export.request.json index 8bb1d14..6fcc6ab 100644 --- a/schemas/json/internal/backup-export.request.json +++ b/schemas/json/internal/backup-export.request.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["wallet_id"], + "required": ["identity_id"], "properties": { - "wallet_id": { "type": "string", "format": "uuid" } + "identity_id": { "type": "string", "format": "uuid" } } } diff --git a/schemas/json/internal/backup-import.request.json b/schemas/json/internal/backup-import.request.json index 3092044..d50767f 100644 --- a/schemas/json/internal/backup-import.request.json +++ b/schemas/json/internal/backup-import.request.json @@ -2,9 +2,9 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["wallet_id", "alg", "private_key"], + "required": ["identity_id", "alg", "private_key"], "properties": { - "wallet_id": { "type": "string", "format": "uuid" }, + "identity_id": { "type": "string", "format": "uuid" }, "alg": { "type": "string", "enum": ["secp256k1"] }, "private_key": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } } diff --git a/schemas/json/internal/destroy.request.json b/schemas/json/internal/destroy.request.json index 8bb1d14..6fcc6ab 100644 --- a/schemas/json/internal/destroy.request.json +++ b/schemas/json/internal/destroy.request.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["wallet_id"], + "required": ["identity_id"], "properties": { - "wallet_id": { "type": "string", "format": "uuid" } + "identity_id": { "type": "string", "format": "uuid" } } } diff --git a/schemas/json/internal/generate.request.json b/schemas/json/internal/generate.request.json index 182cd28..c9b94d0 100644 --- a/schemas/json/internal/generate.request.json +++ b/schemas/json/internal/generate.request.json @@ -2,9 +2,9 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["wallet_id", "alg"], + "required": ["identity_id", "alg"], "properties": { - "wallet_id": { "type": "string", "format": "uuid" }, + "identity_id": { "type": "string", "format": "uuid" }, "alg": { "type": "string", "enum": ["secp256k1"] } } } diff --git a/schemas/json/internal/sign.request.json b/schemas/json/internal/sign.request.json index 660287e..17e4e85 100644 --- a/schemas/json/internal/sign.request.json +++ b/schemas/json/internal/sign.request.json @@ -2,9 +2,9 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["wallet_id", "digest", "ticket"], + "required": ["identity_id", "digest", "ticket"], "properties": { - "wallet_id": { "type": "string", "format": "uuid" }, + "identity_id": { "type": "string", "format": "uuid" }, "digest": { "type": "string", "pattern": "^([a-fA-F0-9]{64}|0x[a-fA-F0-9]{64})$" }, "ticket": { "type": "string", "minLength": 32, "maxLength": 4096 } } diff --git a/schemas/json/public/audit.response.json b/schemas/json/public/audit.response.json index 952207c..f7b51d2 100644 --- a/schemas/json/public/audit.response.json +++ b/schemas/json/public/audit.response.json @@ -7,14 +7,14 @@ "type": "array", "items": { "type": "object", - "required": ["id", "user_id", "wallet_id", "action", "metadata", "created_at"], + "required": ["id", "user_id", "identity_id", "action", "metadata", "created_at"], "properties": { "id": { "type": "string", "format": "uuid" }, "user_id": { "type": "string", "format": "uuid" }, - "wallet_id": { "type": ["string", "null"], "format": "uuid" }, + "identity_id": { "type": ["string", "null"], "format": "uuid" }, "action": { "type": "string", - "enum": ["user.create", "session.create", "wallet.create", "wallet.sign", "wallet.destroy"] + "enum": ["user.create", "session.create", "identity.create", "identity.sign", "identity.destroy"] }, "metadata": { "type": "object", "additionalProperties": true }, "created_at": { "type": "string", "format": "date-time" } diff --git a/schemas/json/public/create-wallet.request.json b/schemas/json/public/create-identity.request.json similarity index 100% rename from schemas/json/public/create-wallet.request.json rename to schemas/json/public/create-identity.request.json diff --git a/schemas/json/public/create-wallet.response.json b/schemas/json/public/create-identity.response.json similarity index 100% rename from schemas/json/public/create-wallet.response.json rename to schemas/json/public/create-identity.response.json diff --git a/schemas/json/public/destroy-wallet.response.json b/schemas/json/public/destroy-identity.response.json similarity index 100% rename from schemas/json/public/destroy-wallet.response.json rename to schemas/json/public/destroy-identity.response.json diff --git a/schemas/json/public/sign-intent.response.json b/schemas/json/public/sign-intent.response.json index d803418..557065f 100644 --- a/schemas/json/public/sign-intent.response.json +++ b/schemas/json/public/sign-intent.response.json @@ -1,10 +1,10 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": ["id", "wallet_id", "digest_hash", "scope", "expires_at", "nonce", "ticket"], + "required": ["id", "identity_id", "digest_hash", "scope", "expires_at", "nonce", "ticket"], "properties": { "id": { "type": "string", "format": "uuid" }, - "wallet_id": { "type": "string", "format": "uuid" }, + "identity_id": { "type": "string", "format": "uuid" }, "digest_hash": { "type": "string" }, "scope": { "type": "string", "enum": ["sign"] }, "expires_at": { "type": "string", "format": "date-time" }, diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 5d8fb8a..96d8740 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -3,7 +3,7 @@ info: title: clw.cash API version: 0.1.0 description: | - MVP API for Create/Sign/Destroy wallet keys with signing delegated to an Evervault Enclave service. + MVP API for Create/Sign/Destroy identity keys with signing delegated to an Evervault Enclave service. servers: - url: https://api.example.com tags: @@ -23,7 +23,7 @@ components: id: { type: string, format: uuid } telegram_user_id: { type: string } created_at: { type: string, format: date-time } - Wallet: + Identity: type: object required: [id, user_id, alg, public_key, status, created_at] properties: @@ -35,10 +35,10 @@ components: created_at: { type: string, format: date-time } TicketIntent: type: object - required: [id, wallet_id, digest_hash, scope, expires_at, nonce, ticket] + required: [id, identity_id, digest_hash, scope, expires_at, nonce, ticket] properties: id: { type: string, format: uuid } - wallet_id: { type: string, format: uuid } + identity_id: { type: string, format: uuid } digest_hash: { type: string } scope: { type: string, enum: [sign] } expires_at: { type: string, format: date-time } @@ -46,18 +46,18 @@ components: ticket: { type: string } AuditEvent: type: object - required: [id, user_id, wallet_id, action, metadata, created_at] + required: [id, user_id, identity_id, action, metadata, created_at] properties: id: { type: string, format: uuid } user_id: { type: string, format: uuid } - wallet_id: + identity_id: oneOf: - type: string format: uuid - type: "null" action: type: string - enum: [user.create, session.create, wallet.create, wallet.sign, wallet.destroy] + enum: [user.create, session.create, identity.create, identity.sign, identity.destroy] metadata: { type: object, additionalProperties: true } created_at: { type: string, format: date-time } Error: @@ -130,12 +130,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /v1/wallets: + /v1/identities: post: tags: [Public API] security: - BearerAuth: [] - summary: Create wallet and keypair through enclave + summary: Create identity and keypair through enclave requestBody: required: false content: @@ -148,12 +148,12 @@ paths: enum: [secp256k1] responses: "201": - description: Wallet created + description: Identity created content: application/json: schema: - $ref: "#/components/schemas/Wallet" - /v1/wallets/{id}/sign-intent: + $ref: "#/components/schemas/Identity" + /v1/identities/{id}/sign-intent: post: tags: [Public API] security: @@ -185,7 +185,7 @@ paths: application/json: schema: $ref: "#/components/schemas/TicketIntent" - /v1/wallets/{id}/sign: + /v1/identities/{id}/sign: post: tags: [Public API] security: @@ -218,12 +218,12 @@ paths: required: [signature] properties: signature: { type: string } - /v1/wallets/{id}: + /v1/identities/{id}: delete: tags: [Public API] security: - BearerAuth: [] - summary: Destroy wallet key in enclave and mark wallet destroyed + summary: Destroy identity key in enclave and mark identity destroyed parameters: - name: id in: path @@ -280,9 +280,9 @@ paths: application/json: schema: type: object - required: [wallet_id, alg] + required: [identity_id, alg] properties: - wallet_id: { type: string, format: uuid } + identity_id: { type: string, format: uuid } alg: { type: string, enum: [secp256k1] } responses: "201": @@ -297,9 +297,9 @@ paths: application/json: schema: type: object - required: [wallet_id, digest, ticket] + required: [identity_id, digest, ticket] properties: - wallet_id: { type: string, format: uuid } + identity_id: { type: string, format: uuid } digest: type: string pattern: "^([a-fA-F0-9]{64}|0x[a-fA-F0-9]{64})$" @@ -310,16 +310,16 @@ paths: /internal/destroy: post: tags: [Internal Enclave API] - summary: Delete wallet key from enclave memory + summary: Delete identity key from enclave memory requestBody: required: true content: application/json: schema: type: object - required: [wallet_id] + required: [identity_id] properties: - wallet_id: { type: string, format: uuid } + identity_id: { type: string, format: uuid } responses: "200": description: Destroyed @@ -333,9 +333,9 @@ paths: application/json: schema: type: object - required: [wallet_id] + required: [identity_id] properties: - wallet_id: { type: string, format: uuid } + identity_id: { type: string, format: uuid } responses: "200": description: Backup export @@ -357,9 +357,9 @@ paths: application/json: schema: type: object - required: [wallet_id, alg, private_key] + required: [identity_id, alg, private_key] properties: - wallet_id: { type: string, format: uuid } + identity_id: { type: string, format: uuid } alg: { type: string, enum: [secp256k1] } private_key: { type: string } responses: diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..3e054ca --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT_DIR/.env.production.local" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { echo -e "${GREEN}[deploy]${NC} $*"; } +warn() { echo -e "${YELLOW}[deploy]${NC} $*"; } +error() { echo -e "${RED}[deploy]${NC} $*" >&2; } +die() { error "$@"; exit 1; } + +# ── Load secrets ────────────────────────────────────────────────────── +load_env() { + if [[ ! -f "$ENV_FILE" ]]; then + die "Missing $ENV_FILE — copy .env.production.local.example and fill in real values" + fi + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +} + +require_var() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + die "Required secret $name is empty in $ENV_FILE" + fi +} + +# ── Service: enclave ────────────────────────────────────────────────── +deploy_enclave() { + info "Deploying enclave..." + load_env + require_var TICKET_SIGNING_SECRET + require_var INTERNAL_API_KEY + require_var SEALING_KEY + + # Deploy (builds Docker image + pushes to Evervault) + info "Building and deploying enclave image..." + ev enclave deploy --config "$ROOT_DIR/enclave.toml" + + # Set env vars (secrets) + info "Setting enclave environment variables..." + local enclave_secrets=(TICKET_SIGNING_SECRET INTERNAL_API_KEY SEALING_KEY) + for var in "${enclave_secrets[@]}"; do + info " Setting $var..." + ev enclave env add \ + --config "$ROOT_DIR/enclave.toml" \ + --key "$var" \ + --value "${!var}" \ + --secret + done + + # Restart to pick up new env vars + info "Restarting enclave..." + ev enclave restart --config "$ROOT_DIR/enclave.toml" + + warn "Remember to update PCR values in infra/enclave.toml if the image changed." + warn "Run: ev enclave describe --config enclave.toml --json | jq '.attestation'" + info "Enclave deploy complete!" +} + +# ── Service: api ────────────────────────────────────────────────────── +deploy_api() { + info "Deploying API (Cloudflare Worker)..." + load_env + require_var INTERNAL_API_KEY + require_var TICKET_SIGNING_SECRET + require_var SESSION_SIGNING_SECRET + require_var EV_API_KEY + + # Set secrets on CF Worker + info "Setting Worker secrets..." + local api_secrets=(INTERNAL_API_KEY TICKET_SIGNING_SECRET SESSION_SIGNING_SECRET EV_API_KEY TELEGRAM_BOT_TOKEN TELEGRAM_BOT_USERNAME) + for var in "${api_secrets[@]}"; do + # Skip optional secrets if empty + if [[ -z "${!var:-}" ]]; then + warn " Skipping $var (empty)" + continue + fi + info " Setting $var..." + echo "${!var}" | wrangler secret put "$var" --env production --config "$ROOT_DIR/api/wrangler.toml" + done + + # Deploy worker + info "Deploying worker..." + wrangler deploy --env production --config "$ROOT_DIR/api/wrangler.toml" + + info "API deploy complete!" +} + +# ── Service: web ────────────────────────────────────────────────────── +deploy_web() { + info "Deploying web (Cloudflare Pages)..." + + info "Building web..." + pnpm --filter @clw-cash/web build + + info "Publishing to Cloudflare Pages..." + wrangler pages deploy "$ROOT_DIR/web/dist" --project-name clw-cash-web + + info "Web deploy complete!" +} + +# ── Service: landing ────────────────────────────────────────────────── +deploy_landing() { + info "Deploying landing page (Cloudflare Pages)..." + + wrangler pages deploy "$ROOT_DIR/landing-page" --project-name claw-cash-landing-page + + info "Landing page deploy complete!" +} + +# ── Service: cli ────────────────────────────────────────────────────── +deploy_cli() { + info "Publishing CLI to npm..." + + info "Building CLI..." + pnpm --filter clw-cash build + + info "Publishing..." + (cd "$ROOT_DIR/cli" && npm publish) + + info "CLI publish complete!" +} + +# ── Orchestration ───────────────────────────────────────────────────── +deploy_all() { + deploy_enclave + deploy_api + deploy_web + deploy_landing + deploy_cli + info "All services deployed!" +} + +# ── Main ────────────────────────────────────────────────────────────── +usage() { + cat < + +Services: + enclave Build, deploy, and configure Evervault Enclave + api Deploy Cloudflare Worker with secrets + web Build and deploy web app to Cloudflare Pages + landing Deploy landing page to Cloudflare Pages + cli Build and publish CLI to npm + all Deploy all services in order +EOF + exit 1 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +case "$1" in + enclave) deploy_enclave ;; + api) deploy_api ;; + web) deploy_web ;; + landing) deploy_landing ;; + cli) deploy_cli ;; + all) deploy_all ;; + *) error "Unknown service: $1"; usage ;; +esac diff --git a/skills/package.json b/skills/package.json new file mode 100644 index 0000000..22b237c --- /dev/null +++ b/skills/package.json @@ -0,0 +1,22 @@ +{ + "name": "@clw-cash/skills", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clw-cash/sdk": "workspace:*", + "@arkade-os/sdk": "^0.3.12", + "@arkade-os/boltz-swap": "^0.2.18", + "@lendasat/lendaswap-sdk-pure": "^0.0.2" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "typescript": "^5.7.3" + } +} diff --git a/skills/src/index.ts b/skills/src/index.ts new file mode 100644 index 0000000..db2bde0 --- /dev/null +++ b/skills/src/index.ts @@ -0,0 +1,108 @@ +export * from "./skills/index.js"; + +export type { + Wallet, + ArkTransaction, + WalletBalance, + ExtendedCoin, + ExtendedVirtualCoin, + FeeInfo, + SettlementEvent, + NetworkName, +} from "@arkade-os/sdk"; + +// ── clw.cash factory ──────────────────────────────────── + +import { Wallet } from "@arkade-os/sdk"; +import { RemoteSignerIdentity, type RemoteSignerConfig } from "@clw-cash/sdk"; +import { ArkadeBitcoinSkill } from "./skills/arkadeBitcoin.js"; +import { ArkadeLightningSkill, type ArkadeLightningSkillConfig } from "./skills/lightning.js"; +import { LendaSwapSkill, type LendaSwapSkillConfig } from "./skills/lendaswap.js"; + +export interface ClwSkillConfig { + /** clw.cash API base URL */ + apiBaseUrl: string; + /** JWT session token from /v1/auth/verify */ + sessionToken: string; + /** UUID of the identity on clw.cash */ + identityId: string; + /** Hex-encoded 33-byte compressed public key */ + publicKey: string; + /** Arkade server URL (e.g., "https://arkade.computer") */ + arkServerUrl: string; +} + +/** + * Create an ArkadeBitcoinSkill powered by a clw.cash remote signer. + * The private key stays in the enclave — only signing requests are sent. + */ +export async function createClwBitcoinSkill( + config: ClwSkillConfig +): Promise { + const identity = new RemoteSignerIdentity({ + apiBaseUrl: config.apiBaseUrl, + identityId: config.identityId, + sessionToken: config.sessionToken, + compressedPublicKey: config.publicKey, + }); + + const wallet = await Wallet.create({ + identity, + arkServerUrl: config.arkServerUrl, + }); + + return new ArkadeBitcoinSkill(wallet); +} + +/** + * Create an ArkadeLightningSkill powered by a clw.cash remote signer. + */ +export async function createClwLightningSkill( + config: ClwSkillConfig & Omit +): Promise { + const identity = new RemoteSignerIdentity({ + apiBaseUrl: config.apiBaseUrl, + identityId: config.identityId, + sessionToken: config.sessionToken, + compressedPublicKey: config.publicKey, + }); + + const wallet = await Wallet.create({ + identity, + arkServerUrl: config.arkServerUrl, + }); + + return new ArkadeLightningSkill({ + wallet, + network: config.network, + arkProvider: config.arkProvider, + indexerProvider: config.indexerProvider, + boltzApiUrl: config.boltzApiUrl, + referralId: config.referralId, + enableSwapManager: config.enableSwapManager, + }); +} + +/** + * Create a LendaSwapSkill powered by a clw.cash remote signer. + */ +export async function createClwLendaSwapSkill( + config: ClwSkillConfig & Omit +): Promise { + const identity = new RemoteSignerIdentity({ + apiBaseUrl: config.apiBaseUrl, + identityId: config.identityId, + sessionToken: config.sessionToken, + compressedPublicKey: config.publicKey, + }); + + const wallet = await Wallet.create({ + identity, + arkServerUrl: config.arkServerUrl, + }); + + return new LendaSwapSkill({ + wallet, + ...config, + }); +} diff --git a/skills/src/skills/arkadeBitcoin.ts b/skills/src/skills/arkadeBitcoin.ts new file mode 100644 index 0000000..037c2cd --- /dev/null +++ b/skills/src/skills/arkadeBitcoin.ts @@ -0,0 +1,209 @@ +import { + Wallet, + Ramps, + ArkTransaction, + ExtendedCoin, + ExtendedVirtualCoin, + type IncomingFunds, +} from "@arkade-os/sdk"; +import type { + BitcoinSkill, + RampSkill, + BitcoinAddress, + SendParams, + SendResult, + BalanceInfo, + IncomingFundsEvent, + OnboardParams, + OffboardParams, + RampResult, +} from "./types.js"; + +export class ArkadeBitcoinSkill implements BitcoinSkill, RampSkill { + readonly name = "arkade-bitcoin"; + readonly description = + "Send and receive Bitcoin over Arkade offchain, get paid onchain (onboard), pay onchain (offboard)"; + readonly version = "1.0.0"; + + private readonly ramps: Ramps; + + constructor(private readonly wallet: Wallet) { + this.ramps = new Ramps(wallet); + } + + async getReceiveAddresses(): Promise { + const [arkAddress, boardingAddress] = await Promise.all([ + this.wallet.getAddress(), + this.wallet.getBoardingAddress(), + ]); + + return [ + { + address: arkAddress, + type: "ark", + description: "Ark address for receiving offchain Bitcoin instantly", + }, + { + address: boardingAddress, + type: "boarding", + description: + "Boarding address for receiving onchain Bitcoin (requires onboarding)", + }, + ]; + } + + async getArkAddress(): Promise { + return this.wallet.getAddress(); + } + + async getBoardingAddress(): Promise { + return this.wallet.getBoardingAddress(); + } + + async getBalance(): Promise { + const walletBalance = await this.wallet.getBalance(); + + return { + total: walletBalance.total, + offchain: { + settled: walletBalance.settled, + preconfirmed: walletBalance.preconfirmed, + available: walletBalance.available, + recoverable: walletBalance.recoverable, + }, + onchain: { + confirmed: walletBalance.boarding.confirmed, + unconfirmed: walletBalance.boarding.unconfirmed, + total: walletBalance.boarding.total, + }, + }; + } + + async send(params: SendParams): Promise { + const txid = await this.wallet.sendBitcoin({ + address: params.address, + amount: params.amount, + feeRate: params.feeRate, + memo: params.memo, + }); + + return { + txid, + type: "ark", + amount: params.amount, + }; + } + + async getTransactionHistory(): Promise { + return this.wallet.getTransactionHistory(); + } + + async waitForIncomingFunds(timeoutMs?: number): Promise { + let stopSubscription: (() => void) | undefined; + let timeoutId: ReturnType | undefined; + let settled = false; + + const fundsPromise = new Promise((resolve, reject) => { + this.wallet + .notifyIncomingFunds((funds: IncomingFunds) => { + if (settled) return; + settled = true; + resolve(funds); + }) + .then((stop: () => void) => { + stopSubscription = stop; + if (settled) stop(); + }) + .catch((error: unknown) => { + if (settled) return; + settled = true; + reject(error); + }); + + if (timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error("Timeout waiting for incoming funds")); + }, timeoutMs); + } + }); + + let result: IncomingFunds; + try { + result = await fundsPromise; + } finally { + if (timeoutId) clearTimeout(timeoutId); + if (stopSubscription) stopSubscription(); + } + + if (result.type === "utxo") { + return { + type: "utxo", + amount: result.coins.reduce((sum: number, coin: { value: number }) => sum + coin.value, 0), + ids: result.coins.map((coin: { txid: string; vout: number }) => `${coin.txid}:${coin.vout}`), + }; + } else { + return { + type: "vtxo", + amount: result.newVtxos.reduce((sum: number, vtxo: { value: number }) => sum + vtxo.value, 0), + ids: result.newVtxos.map((vtxo: { txid: string; vout: number }) => `${vtxo.txid}:${vtxo.vout}`), + }; + } + } + + async onboard(params: OnboardParams): Promise { + const boardingUtxos = await this.wallet.getBoardingUtxos(); + const totalBefore = boardingUtxos.reduce( + (sum: bigint, utxo: { value: number }) => sum + BigInt(utxo.value), + 0n + ); + + const commitmentTxid = await this.ramps.onboard( + params.feeInfo, + undefined, + params.amount, + params.eventCallback + ); + + const amount = params.amount ?? totalBefore; + return { commitmentTxid, amount }; + } + + async offboard(params: OffboardParams): Promise { + const vtxos = await this.wallet.getVtxos({ withRecoverable: true }); + const totalBefore = vtxos.reduce( + (sum: bigint, vtxo: { value: number }) => sum + BigInt(vtxo.value), + 0n + ); + + const commitmentTxid = await this.ramps.offboard( + params.destinationAddress, + params.feeInfo, + params.amount, + params.eventCallback + ); + + const amount = params.amount ?? totalBefore; + return { commitmentTxid, amount }; + } + + getWallet(): Wallet { + return this.wallet; + } + + async getVtxos(filter?: { + withRecoverable?: boolean; + withUnrolled?: boolean; + }): Promise { + return this.wallet.getVtxos(filter); + } + + async getBoardingUtxos(): Promise { + return this.wallet.getBoardingUtxos(); + } +} + +export function createArkadeBitcoinSkill(wallet: Wallet): ArkadeBitcoinSkill { + return new ArkadeBitcoinSkill(wallet); +} diff --git a/skills/src/skills/index.ts b/skills/src/skills/index.ts new file mode 100644 index 0000000..f338f76 --- /dev/null +++ b/skills/src/skills/index.ts @@ -0,0 +1,61 @@ +export type { + Skill, + BitcoinSkill, + RampSkill, + LightningSkill, + StablecoinSwapSkill, + BitcoinAddress, + SendParams, + SendResult, + BalanceInfo, + IncomingFundsEvent, + OnboardParams, + OffboardParams, + RampResult, + LightningInvoice, + CreateInvoiceParams, + PayInvoiceParams, + PaymentResult, + LightningFees, + LightningLimits, + SwapStatus, + SwapInfo, + EvmChain, + StablecoinToken, + BtcSource, + BtcToStablecoinParams, + StablecoinToBtcParams, + StablecoinSwapResult, + StablecoinSwapStatus, + StablecoinSwapInfo, + StablecoinQuote, + StablecoinPair, + EvmFundingCallData, + EvmRefundCallData, + ClaimSwapResult, + RefundSwapResult, +} from "./types.js"; + +export { ArkadeBitcoinSkill, createArkadeBitcoinSkill } from "./arkadeBitcoin.js"; + +export { + ArkadeLightningSkill, + createLightningSkill, + type ArkadeLightningSkillConfig, +} from "./lightning.js"; + +export { + LendaSwapSkill, + createLendaSwapSkill, + mapSwapStatus, + isTerminalStatus, + TOKEN_DECIMALS, + type LendaSwapSkillConfig, +} from "./lendaswap.js"; + +export { + toSatoshi, + fromSatoshi, + toSmallestUnit, + fromSmallestUnit, +} from "./units.js"; diff --git a/skills/src/skills/lendaswap.ts b/skills/src/skills/lendaswap.ts new file mode 100644 index 0000000..9ea46e8 --- /dev/null +++ b/skills/src/skills/lendaswap.ts @@ -0,0 +1,504 @@ +import type { Wallet } from "@arkade-os/sdk"; +import { + Client, + InMemoryWalletStorage, + InMemorySwapStorage, + type WalletStorage, + type SwapStorage as LendaSwapStorage, + type SwapStatus as LendaSwapStatus, +} from "@lendasat/lendaswap-sdk-pure"; +import type { + StablecoinSwapSkill, + StablecoinToken, + BtcToStablecoinParams, + StablecoinToBtcParams, + StablecoinSwapResult, + StablecoinSwapInfo, + StablecoinSwapStatus, + StablecoinQuote, + StablecoinPair, + EvmFundingCallData, + EvmRefundCallData, + ClaimSwapResult, + RefundSwapResult, +} from "./types.js"; + +export const TOKEN_DECIMALS: Record = { + usdc_pol: 6, + usdc_eth: 6, + usdc_arb: 6, + usdt0_pol: 6, + usdt_eth: 6, + usdt_arb: 6, +}; + +export function mapSwapStatus( + sdkStatus: LendaSwapStatus +): StablecoinSwapStatus { + switch (sdkStatus) { + case "pending": + return "pending"; + case "clientfundingseen": + case "clientfunded": + return "funded"; + case "serverfunded": + case "clientredeeming": + return "processing"; + case "clientredeemed": + case "serverredeemed": + case "clientredeemedandclientrefunded": + return "completed"; + case "expired": + case "clientfundedtoolate": + return "expired"; + case "clientrefunded": + case "clientfundedserverrefunded": + case "clientrefundedserverfunded": + case "clientrefundedserverrefunded": + return "refunded"; + case "clientinvalidfunded": + return "failed"; + default: + return "pending"; + } +} + +export function isTerminalStatus(status: StablecoinSwapStatus): boolean { + return ( + status === "completed" || + status === "expired" || + status === "refunded" || + status === "failed" + ); +} + +export interface LendaSwapSkillConfig { + wallet: Wallet; + apiKey?: string; + apiUrl?: string; + esploraUrl?: string; + arkadeServerUrl?: string; + mnemonic?: string; + referralCode?: string; + walletStorage?: WalletStorage; + swapStorage?: LendaSwapStorage; +} + +export class LendaSwapSkill implements StablecoinSwapSkill { + readonly name = "lendaswap"; + readonly description = + "Swap USDC/USDT from/to Arkade via LendaSwap non-custodial exchange"; + readonly version = "2.0.0"; + + private readonly wallet: Wallet; + private readonly referralCode?: string; + private readonly config: LendaSwapSkillConfig; + private client: Client | null = null; + + constructor(config: LendaSwapSkillConfig) { + this.wallet = config.wallet; + this.referralCode = config.referralCode; + this.config = config; + } + + private async getClient(): Promise { + if (this.client) return this.client; + + const builder = Client.builder() + .withSignerStorage( + this.config.walletStorage || new InMemoryWalletStorage() + ) + .withSwapStorage(this.config.swapStorage || new InMemorySwapStorage()); + + if (this.config.apiUrl) builder.withBaseUrl(this.config.apiUrl); + if (this.config.apiKey) builder.withApiKey(this.config.apiKey); + if (this.config.esploraUrl) builder.withEsploraUrl(this.config.esploraUrl); + if (this.config.arkadeServerUrl) + builder.withArkadeServerUrl(this.config.arkadeServerUrl); + if (this.config.mnemonic) builder.withMnemonic(this.config.mnemonic); + + this.client = await builder.build(); + return this.client; + } + + async isAvailable(): Promise { + try { + const client = await this.getClient(); + const result = await client.healthCheck(); + return result === "ok"; + } catch { + return false; + } + } + + async getMnemonic(): Promise { + const client = await this.getClient(); + return client.getMnemonic(); + } + + async getVersion(): Promise<{ tag: string; commit_hash: string }> { + const client = await this.getClient(); + return client.getVersion(); + } + + async getQuoteBtcToStablecoin( + sourceAmount: number, + targetToken: StablecoinToken + ): Promise { + const client = await this.getClient(); + const quote = await client.getQuote( + "btc_arkade", + targetToken, + sourceAmount + ); + + const rate = parseFloat(quote.exchange_rate); + const netSats = sourceAmount - quote.protocol_fee - quote.network_fee; + const targetAmount = (netSats / 1e8) * rate; + + return { + sourceToken: "btc_arkade", + targetToken, + sourceAmount, + targetAmount, + exchangeRate: rate, + fee: { + amount: quote.protocol_fee + quote.network_fee, + percentage: quote.protocol_fee_rate * 100, + }, + expiresAt: new Date(Date.now() + 60_000), + }; + } + + async getQuoteStablecoinToBtc( + sourceAmount: number, + sourceToken: StablecoinToken + ): Promise { + const client = await this.getClient(); + const quote = await client.getQuote( + sourceToken, + "btc_arkade", + sourceAmount + ); + + const rate = parseFloat(quote.exchange_rate); + const grossSats = (sourceAmount / rate) * 1e8; + const targetAmount = grossSats - quote.protocol_fee - quote.network_fee; + + return { + sourceToken, + targetToken: "btc_arkade", + sourceAmount, + targetAmount: Math.max(0, Math.floor(targetAmount)), + exchangeRate: rate, + fee: { + amount: quote.protocol_fee + quote.network_fee, + percentage: quote.protocol_fee_rate * 100, + }, + expiresAt: new Date(Date.now() + 60_000), + }; + } + + async swapBtcToStablecoin( + params: BtcToStablecoinParams + ): Promise { + const client = await this.getClient(); + + const result = await client.createArkadeToEvmSwap({ + targetAddress: params.targetAddress, + targetToken: params.targetToken, + targetChain: params.targetChain, + sourceAmount: params.sourceAmount, + targetAmount: params.targetAmount, + referralCode: params.referralCode || this.referralCode, + }); + + const resp = result.response; + + const fundingTxid = await this.wallet.sendBitcoin({ + address: resp.htlc_address_arkade, + amount: resp.source_amount, + }); + + const exchangeRate = + resp.source_amount > 0 && resp.target_amount > 0 + ? resp.target_amount / (resp.source_amount / 1e8) + : 0; + + return { + swapId: resp.id, + status: "funded", + sourceAmount: resp.source_amount, + targetAmount: resp.target_amount, + exchangeRate, + fee: { + amount: resp.fee_sats, + percentage: + resp.source_amount > 0 + ? (resp.fee_sats / resp.source_amount) * 100 + : 0, + }, + expiresAt: new Date(resp.vhtlc_refund_locktime * 1000), + paymentDetails: { address: resp.htlc_address_arkade }, + htlcAddressEvm: resp.htlc_address_evm, + fundingTxid, + }; + } + + async swapStablecoinToBtc( + params: StablecoinToBtcParams + ): Promise { + const client = await this.getClient(); + const arkAddress = params.targetAddress || (await this.wallet.getAddress()); + + const result = await client.createEvmToArkadeSwap({ + sourceChain: params.sourceChain, + sourceToken: params.sourceToken, + sourceAmount: params.sourceAmount, + targetAddress: arkAddress, + userAddress: params.userAddress || "0x0000000000000000000000000000000000000000", + referralCode: params.referralCode || this.referralCode, + }); + + const resp = result.response; + + const exchangeRate = + resp.source_amount > 0 && resp.target_amount > 0 + ? (resp.source_amount / resp.target_amount) * 1e8 + : 0; + + return { + swapId: resp.id, + status: mapSwapStatus(resp.status), + sourceAmount: resp.source_amount, + targetAmount: resp.target_amount, + exchangeRate, + fee: { + amount: resp.fee_sats, + percentage: + resp.source_amount > 0 + ? (resp.fee_sats / resp.target_amount) * 100 + : 0, + }, + expiresAt: new Date(resp.evm_refund_locktime * 1000), + paymentDetails: { + address: resp.htlc_address_evm, + callData: resp.source_token_address, + }, + htlcAddressEvm: resp.htlc_address_evm, + }; + } + + async getSwapStatus(swapId: string): Promise { + const client = await this.getClient(); + const data = await client.getSwap(swapId, { updateStorage: true }); + + const direction = + data.direction === "evm_to_btc" + ? ("stablecoin_to_btc" as const) + : ("btc_to_stablecoin" as const); + + const status = mapSwapStatus(data.status); + + const exchangeRate = + data.source_amount > 0 && data.target_amount > 0 + ? direction === "btc_to_stablecoin" + ? data.target_amount / (data.source_amount / 1e8) + : (data.source_amount / data.target_amount) * 1e8 + : 0; + + return { + id: swapId, + direction, + status, + sourceToken: data.source_token, + targetToken: data.target_token, + sourceAmount: data.source_amount, + targetAmount: data.target_amount, + exchangeRate, + createdAt: new Date(data.created_at), + completedAt: status === "completed" ? new Date() : undefined, + txid: + ("evm_htlc_claim_txid" in data + ? data.evm_htlc_claim_txid + : undefined) ?? undefined, + }; + } + + async getPendingSwaps(): Promise { + const client = await this.getClient(); + const allSwaps = await client.listAllSwaps(); + + const pending: StablecoinSwapInfo[] = []; + for (const stored of allSwaps) { + const status = mapSwapStatus(stored.response.status); + if (!isTerminalStatus(status)) { + try { + const info = await this.getSwapStatus(stored.swapId); + pending.push(info); + } catch { + pending.push(this.storedSwapToInfo(stored)); + } + } + } + return pending; + } + + async getSwapHistory(): Promise { + const client = await this.getClient(); + const allSwaps = await client.listAllSwaps(); + + return allSwaps + .map((stored) => this.storedSwapToInfo(stored)) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + + async getAvailablePairs(): Promise { + const client = await this.getClient(); + const pairs = await client.getAssetPairs(); + + return pairs.map((pair) => { + const p = pair as typeof pair & { + min_amount?: number; + max_amount?: number; + fee_rate?: number; + }; + return { + from: p.source.token_id, + to: p.target.token_id, + minAmount: p.min_amount ?? 0, + maxAmount: p.max_amount ?? 0, + feePercentage: p.fee_rate != null ? p.fee_rate * 100 : 0, + }; + }); + } + + async claimSwap(swapId: string): Promise { + const client = await this.getClient(); + const result = await client.claim(swapId); + + return { + success: result.success, + message: result.message, + txHash: result.txHash, + chain: result.chain, + }; + } + + async refundSwap( + swapId: string, + options?: { destinationAddress?: string } + ): Promise { + const client = await this.getClient(); + const data = await client.getSwap(swapId, { updateStorage: true }); + + if (data.direction === "evm_to_btc") { + return { + success: false, + message: + "This is an EVM→BTC swap. Use getEvmRefundCallData() to get the EVM refund transaction data.", + }; + } + + let destinationAddress = options?.destinationAddress; + if (!destinationAddress) { + if (data.source_token === "btc_arkade") { + destinationAddress = await this.wallet.getAddress(); + } else { + destinationAddress = await this.wallet.getBoardingAddress(); + } + } + + const result = await client.refundSwap(swapId, { destinationAddress: destinationAddress! }); + + return { + success: result.success, + message: result.message, + txId: result.txId, + refundAmount: result.refundAmount + ? Number(result.refundAmount) + : undefined, + }; + } + + async getEvmFundingCallData( + swapId: string, + tokenDecimals: number + ): Promise { + const client = await this.getClient(); + const data = await client.getEvmFundingCallData(swapId, tokenDecimals); + return { + approve: { to: data.approve.to, data: data.approve.data }, + createSwap: { to: data.createSwap.to, data: data.createSwap.data }, + }; + } + + async getEvmRefundCallData(swapId: string): Promise { + const client = await this.getClient(); + const data = await client.getEvmRefundCallData(swapId); + return { + to: data.to, + data: data.data, + timelockExpired: data.timelockExpired, + timelockExpiry: data.timelockExpiry, + }; + } + + getWallet(): Wallet { + return this.wallet; + } + + getTokenDecimals(token: StablecoinToken): number { + return TOKEN_DECIMALS[token] || 6; + } + + private storedSwapToInfo(stored: { + swapId: string; + response: { + status: LendaSwapStatus; + source_token: string; + target_token: string; + source_amount: number; + target_amount: number; + created_at: string; + direction: string; + }; + }): StablecoinSwapInfo { + const resp = stored.response; + const direction = + resp.direction === "evm_to_btc" + ? ("stablecoin_to_btc" as const) + : ("btc_to_stablecoin" as const); + + const status = mapSwapStatus(resp.status); + const exchangeRate = + resp.source_amount > 0 && resp.target_amount > 0 + ? direction === "btc_to_stablecoin" + ? resp.target_amount / (resp.source_amount / 1e8) + : (resp.source_amount / resp.target_amount) * 1e8 + : 0; + + return { + id: stored.swapId, + direction, + status, + sourceToken: resp.source_token, + targetToken: resp.target_token, + sourceAmount: resp.source_amount, + targetAmount: resp.target_amount, + exchangeRate, + createdAt: new Date(resp.created_at), + completedAt: status === "completed" ? new Date() : undefined, + }; + } +} + +export function createLendaSwapSkill( + wallet: Wallet, + options?: Partial> +): LendaSwapSkill { + return new LendaSwapSkill({ + wallet, + ...options, + }); +} diff --git a/skills/src/skills/lightning.ts b/skills/src/skills/lightning.ts new file mode 100644 index 0000000..47b52b5 --- /dev/null +++ b/skills/src/skills/lightning.ts @@ -0,0 +1,227 @@ +import { + ArkadeLightning, + BoltzSwapProvider, + decodeInvoice, + type PendingReverseSwap, + type PendingSubmarineSwap, +} from "@arkade-os/boltz-swap"; +import { Wallet, type ArkProvider, type NetworkName } from "@arkade-os/sdk"; +import type { IndexerProvider } from "@arkade-os/sdk"; +import type { + LightningSkill, + LightningInvoice, + CreateInvoiceParams, + PayInvoiceParams, + PaymentResult, + LightningFees, + LightningLimits, + SwapInfo, + SwapStatus, +} from "./types.js"; + +const BOLTZ_API_URLS: Record = { + bitcoin: "https://api.ark.boltz.exchange", + mainnet: "https://api.ark.boltz.exchange", + testnet: "https://testnet.boltz.exchange/api", + signet: "https://testnet.boltz.exchange/api", + regtest: "http://localhost:9069", + mutinynet: "https://api.boltz.mutinynet.arkade.sh", +}; + +export interface ArkadeLightningSkillConfig { + wallet: Wallet; + network: NetworkName; + arkProvider?: ArkProvider; + indexerProvider?: IndexerProvider; + boltzApiUrl?: string; + referralId?: string; + enableSwapManager?: boolean; +} + +export class ArkadeLightningSkill implements LightningSkill { + readonly name = "arkade-lightning"; + readonly description = + "Lightning Network payments via Boltz submarine swaps for Arkade wallets"; + readonly version = "1.0.0"; + + private readonly arkadeLightning: ArkadeLightning; + private readonly swapProvider: BoltzSwapProvider; + private readonly network: NetworkName; + private readonly swapErrors = new Map(); + + constructor(config: ArkadeLightningSkillConfig) { + this.network = config.network; + + const boltzApiUrl = + config.boltzApiUrl || + BOLTZ_API_URLS[config.network] || + BOLTZ_API_URLS.bitcoin; + + this.swapProvider = new BoltzSwapProvider({ + apiUrl: boltzApiUrl, + network: config.network, + referralId: config.referralId, + }); + + this.arkadeLightning = new ArkadeLightning({ + wallet: config.wallet as ConstructorParameters< + typeof ArkadeLightning + >[0]["wallet"], + swapProvider: this.swapProvider, + arkProvider: config.arkProvider, + indexerProvider: config.indexerProvider, + swapManager: config.enableSwapManager + ? { enableAutoActions: true, autoStart: true } + : undefined, + }); + + // Track swap errors from SwapManager for debugging + const manager = this.arkadeLightning.getSwapManager?.(); + if (manager) { + manager.onSwapFailed?.((swap: { id: string }, error: unknown) => { + const msg = error instanceof Error ? error.message : String(error); + this.swapErrors.set(swap.id, msg); + console.error(`[lightning] swap ${swap.id} failed: ${msg}`); + }); + } + } + + async isAvailable(): Promise { + try { + await this.swapProvider.getFees(); + return true; + } catch { + return false; + } + } + + async createInvoice(params: CreateInvoiceParams): Promise { + const response = await this.arkadeLightning.createLightningInvoice({ + amount: params.amount, + description: params.description, + }); + + const decoded = decodeInvoice(response.invoice); + + return { + bolt11: response.invoice, + paymentHash: response.paymentHash, + amount: response.amount, + description: params.description, + expirySeconds: decoded.expiry, + createdAt: new Date(), + preimage: response.preimage, + }; + } + + async payInvoice(params: PayInvoiceParams): Promise { + const response = await this.arkadeLightning.sendLightningPayment({ + invoice: params.bolt11, + }); + + return { + preimage: response.preimage, + amount: response.amount, + txid: response.txid, + }; + } + + async getFees(): Promise { + return this.arkadeLightning.getFees(); + } + + async getLimits(): Promise { + return this.arkadeLightning.getLimits(); + } + + async getPendingSwaps(): Promise { + // Refresh statuses from Boltz API before returning + try { + await this.arkadeLightning.refreshSwapsStatus(); + } catch { + // Non-fatal: return stale data if refresh fails + } + + // Return ALL swaps from storage (not just initial-status filtered) + return this.getSwapHistory(); + } + + async getSwapHistory(): Promise { + const history = await this.arkadeLightning.getSwapHistory(); + return history.map((swap) => + swap.type === "reverse" + ? this.mapReverseSwap(swap as PendingReverseSwap) + : this.mapSubmarineSwap(swap as PendingSubmarineSwap) + ); + } + + async waitAndClaim( + pendingSwap: PendingReverseSwap + ): Promise<{ txid: string }> { + return this.arkadeLightning.waitAndClaim(pendingSwap); + } + + getArkadeLightning(): ArkadeLightning { + return this.arkadeLightning; + } + + getSwapProvider(): BoltzSwapProvider { + return this.swapProvider; + } + + async startSwapManager(): Promise { + await this.arkadeLightning.startSwapManager(); + } + + async stopSwapManager(): Promise { + await this.arkadeLightning.stopSwapManager(); + } + + async dispose(): Promise { + await this.arkadeLightning.dispose(); + } + + private mapReverseSwap(swap: PendingReverseSwap): SwapInfo { + return { + id: swap.id, + type: "reverse", + status: swap.status as SwapStatus, + amount: swap.response.onchainAmount, + createdAt: new Date(swap.createdAt), + invoice: swap.response.invoice, + error: this.swapErrors.get(swap.id), + }; + } + + private mapSubmarineSwap(swap: PendingSubmarineSwap): SwapInfo { + let amount = 0; + try { + const decoded = decodeInvoice(swap.request.invoice); + amount = decoded.amountSats; + } catch { + amount = swap.response.expectedAmount; + } + + return { + id: swap.id, + type: "submarine", + status: swap.status as SwapStatus, + amount, + createdAt: new Date(swap.createdAt), + invoice: swap.request.invoice, + error: this.swapErrors.get(swap.id), + }; + } +} + +export function createLightningSkill( + wallet: Wallet, + network: NetworkName, + options?: Partial> +): ArkadeLightningSkill { + return new ArkadeLightningSkill({ + wallet, + network, + ...options, + }); +} diff --git a/skills/src/skills/types.ts b/skills/src/skills/types.ts new file mode 100644 index 0000000..ddc9c47 --- /dev/null +++ b/skills/src/skills/types.ts @@ -0,0 +1,301 @@ +import type { ArkTransaction, SettlementEvent, FeeInfo } from "@arkade-os/sdk"; + +export interface Skill { + readonly name: string; + readonly description: string; + readonly version: string; +} + +export interface BitcoinAddress { + address: string; + type: "ark" | "boarding" | "onchain"; + description: string; +} + +export interface SendParams { + address: string; + amount: number; + feeRate?: number; + memo?: string; +} + +export interface OnboardParams { + feeInfo: FeeInfo; + amount?: bigint; + eventCallback?: (event: SettlementEvent) => void; +} + +export interface OffboardParams { + destinationAddress: string; + feeInfo: FeeInfo; + amount?: bigint; + eventCallback?: (event: SettlementEvent) => void; +} + +export interface SendResult { + txid: string; + type: "ark" | "onchain" | "lightning"; + amount: number; + fee?: number; +} + +export interface RampResult { + commitmentTxid: string; + amount: bigint; +} + +export interface BalanceInfo { + total: number; + offchain: { + settled: number; + preconfirmed: number; + available: number; + recoverable: number; + }; + onchain: { + confirmed: number; + unconfirmed: number; + total: number; + }; +} + +export interface IncomingFundsEvent { + type: "utxo" | "vtxo"; + amount: number; + ids: string[]; +} + +export interface BitcoinSkill extends Skill { + getReceiveAddresses(): Promise; + getArkAddress(): Promise; + getBoardingAddress(): Promise; + getBalance(): Promise; + send(params: SendParams): Promise; + getTransactionHistory(): Promise; + waitForIncomingFunds(timeoutMs?: number): Promise; +} + +export interface RampSkill extends Skill { + onboard(params: OnboardParams): Promise; + offboard(params: OffboardParams): Promise; +} + +export interface LightningInvoice { + bolt11: string; + paymentHash: string; + amount: number; + description?: string; + expirySeconds: number; + createdAt: Date; + preimage?: string; +} + +export interface CreateInvoiceParams { + amount: number; + description?: string; +} + +export interface PayInvoiceParams { + bolt11: string; +} + +export interface PaymentResult { + preimage: string; + amount: number; + txid: string; +} + +export interface LightningFees { + submarine: { + percentage: number; + minerFees: number; + }; + reverse: { + percentage: number; + minerFees: { + lockup: number; + claim: number; + }; + }; +} + +export interface LightningLimits { + min: number; + max: number; +} + +export type SwapStatus = + | "pending" + | "invoice.set" + | "invoice.pending" + | "invoice.paid" + | "invoice.settled" + | "invoice.expired" + | "invoice.failedToPay" + | "swap.created" + | "swap.expired" + | "transaction.mempool" + | "transaction.confirmed" + | "transaction.claimed" + | "transaction.refunded" + | "transaction.failed" + | "transaction.lockupFailed" + | "transaction.claim.pending"; + +export interface SwapInfo { + id: string; + type: "submarine" | "reverse"; + status: SwapStatus; + amount: number; + createdAt: Date; + invoice?: string; + error?: string; +} + +export interface LightningSkill extends Skill { + createInvoice(params: CreateInvoiceParams): Promise; + payInvoice(params: PayInvoiceParams): Promise; + isAvailable(): Promise; + getFees(): Promise; + getLimits(): Promise; + getPendingSwaps(): Promise; + getSwapHistory(): Promise; +} + +// ── LendaSwap Types ───────────────────────────────────── + +export type EvmChain = "polygon" | "ethereum" | "arbitrum"; + +export type StablecoinToken = + | "usdc_pol" + | "usdc_eth" + | "usdc_arb" + | "usdt0_pol" + | "usdt_eth" + | "usdt_arb"; + +export type BtcSource = "btc_arkade" | "btc_lightning" | "btc_onchain"; + +export interface BtcToStablecoinParams { + targetAddress: string; + targetToken: StablecoinToken; + targetChain: EvmChain; + sourceAmount?: number; + targetAmount?: number; + referralCode?: string; +} + +export interface StablecoinToBtcParams { + sourceChain: EvmChain; + sourceToken: StablecoinToken; + sourceAmount: number; + targetAddress: string; + userAddress?: string; + referralCode?: string; +} + +export interface StablecoinSwapResult { + swapId: string; + status: StablecoinSwapStatus; + sourceAmount: number; + targetAmount: number; + exchangeRate: number; + fee: { + amount: number; + percentage: number; + }; + expiresAt: Date; + paymentDetails?: { + address: string; + callData?: string; + }; + htlcAddressEvm?: string; + fundingTxid?: string; +} + +export type StablecoinSwapStatus = + | "pending" + | "awaiting_funding" + | "funded" + | "processing" + | "completed" + | "expired" + | "refunded" + | "failed"; + +export interface StablecoinSwapInfo { + id: string; + direction: "btc_to_stablecoin" | "stablecoin_to_btc"; + status: StablecoinSwapStatus; + sourceToken: string; + targetToken: string; + sourceAmount: number; + targetAmount: number; + exchangeRate: number; + createdAt: Date; + completedAt?: Date; + txid?: string; +} + +export interface StablecoinQuote { + sourceToken: string; + targetToken: string; + sourceAmount: number; + targetAmount: number; + exchangeRate: number; + fee: { + amount: number; + percentage: number; + }; + expiresAt: Date; +} + +export interface StablecoinPair { + from: string; + to: string; + minAmount: number; + maxAmount: number; + feePercentage: number; +} + +export interface EvmFundingCallData { + approve: { to: string; data: string }; + createSwap: { to: string; data: string }; +} + +export interface EvmRefundCallData { + to: string; + data: string; + timelockExpired: boolean; + timelockExpiry: number; +} + +export interface ClaimSwapResult { + success: boolean; + message: string; + txHash?: string; + chain?: string; +} + +export interface RefundSwapResult { + success: boolean; + message: string; + txId?: string; + refundAmount?: number; +} + +export interface StablecoinSwapSkill extends Skill { + isAvailable(): Promise; + getQuoteBtcToStablecoin(sourceAmount: number, targetToken: StablecoinToken): Promise; + getQuoteStablecoinToBtc(sourceAmount: number, sourceToken: StablecoinToken): Promise; + swapBtcToStablecoin(params: BtcToStablecoinParams): Promise; + swapStablecoinToBtc(params: StablecoinToBtcParams): Promise; + getSwapStatus(swapId: string): Promise; + getPendingSwaps(): Promise; + getSwapHistory(): Promise; + getAvailablePairs(): Promise; + claimSwap(swapId: string): Promise; + refundSwap(swapId: string, options?: { destinationAddress?: string }): Promise; + getEvmFundingCallData(swapId: string, tokenDecimals: number): Promise; + getEvmRefundCallData(swapId: string): Promise; +} diff --git a/skills/src/skills/units.ts b/skills/src/skills/units.ts new file mode 100644 index 0000000..a79e807 --- /dev/null +++ b/skills/src/skills/units.ts @@ -0,0 +1,26 @@ +import { TOKEN_DECIMALS } from "./lendaswap.js"; +import type { StablecoinToken } from "./types.js"; + +const SATS_PER_BTC = 1e8; + +/** Convert BTC to satoshis */ +export function toSatoshi(btc: number): number { + return Math.round(btc * SATS_PER_BTC); +} + +/** Convert satoshis to BTC */ +export function fromSatoshi(sats: number): number { + return sats / SATS_PER_BTC; +} + +/** Convert a human-readable stablecoin amount to its smallest unit (e.g. 2.5 USDC → 2500000) */ +export function toSmallestUnit(amount: number, token: StablecoinToken): number { + const decimals = TOKEN_DECIMALS[token] ?? 6; + return Math.round(amount * 10 ** decimals); +} + +/** Convert from smallest unit back to human-readable (e.g. 2500000 → 2.5 USDC) */ +export function fromSmallestUnit(smallest: number, token: StablecoinToken): number { + const decimals = TOKEN_DECIMALS[token] ?? 6; + return smallest / 10 ** decimals; +} diff --git a/skills/tsconfig.json b/skills/tsconfig.json new file mode 100644 index 0000000..33d3769 --- /dev/null +++ b/skills/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"], + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/test/authMonitor.test.ts b/test/authMonitor.test.ts new file mode 100644 index 0000000..12b4092 --- /dev/null +++ b/test/authMonitor.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AuthMonitor, type PendingAuth } from "../cli/src/authMonitor.js"; + +// Mock config module so AuthMonitor doesn't touch the real filesystem +vi.mock("../cli/src/config.js", () => ({ + loadConfig: () => ({ + apiBaseUrl: "https://api.test", + sessionToken: "old-token", + identityId: "id-123", + publicKey: "pub-abc", + arkServerUrl: "https://ark.test", + network: "testnet", + }), + saveConfig: vi.fn(), +})); + +function makePendingAuth(overrides?: Partial): PendingAuth { + return { + challengeId: "challenge-1", + deepLink: "https://t.me/bot?start=challenge-1", + apiBaseUrl: "https://api.test", + botToken: "123:ABC", + chatId: 456, + messageId: 789, + createdAt: Date.now(), + ...overrides, + }; +} + +describe("AuthMonitor", () => { + let monitor: AuthMonitor; + + beforeEach(() => { + monitor = new AuthMonitor(); + vi.useFakeTimers(); + }); + + afterEach(() => { + monitor.stop(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("starts idle", () => { + expect(monitor.getStatus()).toEqual({ status: "idle" }); + }); + + it("reports polling status after watch()", () => { + // Prevent actual fetch calls + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 202 })); + + monitor.watch(makePendingAuth()); + expect(monitor.getStatus()).toEqual({ + status: "polling", + challengeId: "challenge-1", + }); + }); + + it("returns to idle after stop()", () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 202 })); + + monitor.watch(makePendingAuth()); + monitor.stop(); + expect(monitor.getStatus()).toEqual({ status: "idle" }); + }); + + it("polls /v1/auth/verify and handles 202 (not yet resolved)", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 202 }); + vi.stubGlobal("fetch", fetchMock); + + monitor.watch(makePendingAuth()); + + // Let the initial poll run + await vi.advanceTimersByTimeAsync(0); + + // Should have called verify endpoint + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/v1/auth/verify", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ challenge_id: "challenge-1" }), + }) + ); + + // Still polling + expect(monitor.getStatus()).toEqual({ + status: "polling", + challengeId: "challenge-1", + }); + }); + + it("on successful verify: saves config, restores identity, sends Telegram reply", async () => { + const { saveConfig } = await import("../cli/src/config.js"); + + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.includes("/v1/auth/verify")) { + return Promise.resolve({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + token: "new-jwt", + expires_in: 3600, + user: { id: "u1", telegram_user_id: "tg1", status: "active" }, + }), + }); + } + // identity restore + if (typeof url === "string" && url.includes("/v1/identities/")) { + return Promise.resolve({ ok: true, text: () => Promise.resolve("ok") }); + } + // Telegram sendMessage + if (typeof url === "string" && url.includes("api.telegram.org")) { + return Promise.resolve({ ok: true, text: () => Promise.resolve("ok") }); + } + return Promise.resolve({ status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + monitor.watch(makePendingAuth()); + await vi.advanceTimersByTimeAsync(0); + + // Config was saved with new token + expect(saveConfig).toHaveBeenCalledWith( + expect.objectContaining({ sessionToken: "new-jwt" }) + ); + + // Identity restore was called + const restoreCall = fetchMock.mock.calls.find( + (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("/v1/identities/id-123/restore") + ); + expect(restoreCall).toBeDefined(); + + // Telegram reply was sent + const telegramCall = fetchMock.mock.calls.find( + (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("api.telegram.org") + ); + expect(telegramCall).toBeDefined(); + const telegramBody = JSON.parse((telegramCall![1] as { body: string }).body); + expect(telegramBody).toEqual({ + chat_id: 456, + text: "Connected, welcome back!", + reply_to_message_id: 789, + }); + + // Monitor stopped itself + expect(monitor.getStatus()).toEqual({ status: "idle" }); + }); + + it("sends timeout message after 120s", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.includes("/v1/auth/verify")) { + return Promise.resolve({ status: 202 }); + } + // Telegram sendMessage + if (typeof url === "string" && url.includes("api.telegram.org")) { + return Promise.resolve({ ok: true, text: () => Promise.resolve("ok") }); + } + return Promise.resolve({ status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + monitor.watch(makePendingAuth({ createdAt: Date.now() })); + + // Advance past the 120s timeout + await vi.advanceTimersByTimeAsync(122_000); + + // Telegram timeout message was sent + const telegramCall = fetchMock.mock.calls.find( + (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("api.telegram.org") + ); + expect(telegramCall).toBeDefined(); + const telegramBody = JSON.parse((telegramCall![1] as { body: string }).body); + expect(telegramBody.text).toBe("Login timed out. Please try again."); + + // Monitor stopped + expect(monitor.getStatus()).toEqual({ status: "idle" }); + }); + + it("replaces previous auth when watch() is called again", () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 202 })); + + monitor.watch(makePendingAuth({ challengeId: "first" })); + expect(monitor.getStatus()).toEqual({ status: "polling", challengeId: "first" }); + + monitor.watch(makePendingAuth({ challengeId: "second" })); + expect(monitor.getStatus()).toEqual({ status: "polling", challengeId: "second" }); + }); +}); diff --git a/test/e2e.test.ts b/test/e2e.test.ts new file mode 100644 index 0000000..0092e16 --- /dev/null +++ b/test/e2e.test.ts @@ -0,0 +1,695 @@ +import { type ChildProcess, execSync, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const API_PORT = 14_000 + Math.floor(Math.random() * 1000); +const ENCLAVE_PORT = 17_000 + Math.floor(Math.random() * 1000); +const API_BASE = `http://127.0.0.1:${API_PORT}`; +const ENCLAVE_BASE = `http://127.0.0.1:${ENCLAVE_PORT}`; + +const INTERNAL_API_KEY = "e2e-test-key"; +const TICKET_SECRET = "e2e-ticket-secret"; +const SESSION_SECRET = "e2e-session-secret"; +const TELEGRAM_USER_ID = "e2e_user_" + Date.now(); + +const env = { + ...process.env, + INTERNAL_API_KEY, + TICKET_SIGNING_SECRET: TICKET_SECRET, + SESSION_SIGNING_SECRET: SESSION_SECRET, + // No TELEGRAM_BOT_TOKEN + ALLOW_TEST_AUTH — enables test mode (auto-resolve challenges) + ALLOW_TEST_AUTH: "true", + BACKUP_FILE_PATH: `/tmp/clw-e2e-backups-${Date.now()}.json`, +}; + +const ROOT = process.cwd(); +const API_DIR = resolve(ROOT, "api"); +const WRANGLER = resolve(API_DIR, "node_modules/.bin/wrangler"); +const PERSIST_DIR = mkdtempSync(join(tmpdir(), "clw-e2e-")); + +let enclaveProc: ChildProcess; +let apiProc: ChildProcess; + +async function waitForHealth(url: string, maxMs = 30_000): Promise { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/health`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`Service at ${url} did not become healthy within ${maxMs}ms`); +} + +function spawnEnclave(): ChildProcess { + const tsx = resolve(ROOT, "enclave/node_modules/.bin/tsx"); + const proc = spawn(tsx, [resolve(ROOT, "enclave/src/index.ts")], { + cwd: ROOT, + env: { ...env, ENCLAVE_PORT: String(ENCLAVE_PORT), ENCLAVE_DEV_MODE: "true" }, + stdio: ["ignore", "pipe", "pipe"], + }); + proc.stdout?.on("data", (d: Buffer) => + process.stderr.write(`[enclave] ${d}`) + ); + proc.stderr?.on("data", (d: Buffer) => + process.stderr.write(`[enclave:err] ${d}`) + ); + return proc; +} + +function spawnApi(): ChildProcess { + const proc = spawn( + WRANGLER, + [ + "dev", + "--port", String(API_PORT), + "--persist-to", PERSIST_DIR, + "--var", `INTERNAL_API_KEY:${INTERNAL_API_KEY}`, + "--var", `TICKET_SIGNING_SECRET:${TICKET_SECRET}`, + "--var", `SESSION_SIGNING_SECRET:${SESSION_SECRET}`, + "--var", `ENCLAVE_BASE_URL:${ENCLAVE_BASE}`, + "--var", "TELEGRAM_BOT_TOKEN:", + "--var", "TELEGRAM_BOT_USERNAME:", + "--var", "EV_API_KEY:", + "--var", "ALLOW_TEST_AUTH:true", + "--show-interactive-dev-session", "false", + ], + { + cwd: API_DIR, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + proc.stdout?.on("data", (d: Buffer) => + process.stderr.write(`[api] ${d}`) + ); + proc.stderr?.on("data", (d: Buffer) => + process.stderr.write(`[api:err] ${d}`) + ); + return proc; +} + +function killProc(proc: ChildProcess | undefined): Promise { + if (!proc || proc.killed) return Promise.resolve(); + return new Promise((resolve) => { + proc.on("exit", () => resolve()); + proc.kill("SIGTERM"); + // Force kill after 3s + setTimeout(() => { + if (!proc.killed) proc.kill("SIGKILL"); + resolve(); + }, 3000); + }); +} + +// ── Lifecycle ────────────────────────────────────────────── + +beforeAll(async () => { + // Apply D1 migrations locally before starting wrangler dev + execSync( + `${WRANGLER} d1 migrations apply clw-cash-db --local --persist-to ${PERSIST_DIR}`, + { cwd: API_DIR, stdio: "pipe" }, + ); + + enclaveProc = spawnEnclave(); + apiProc = spawnApi(); + + await Promise.all([ + waitForHealth(ENCLAVE_BASE), + waitForHealth(API_BASE), + ]); +}, 45_000); + +afterAll(async () => { + await Promise.all([killProc(apiProc), killProc(enclaveProc)]); +}); + +// ── Helpers ──────────────────────────────────────────────── + +async function post(path: string, body: unknown, token?: string) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + return { status: res.status, json: await res.json() }; +} + +async function del(path: string, token: string) { + const res = await fetch(`${API_BASE}${path}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + return { status: res.status, json: await res.json() }; +} + +async function get(path: string, token: string) { + const res = await fetch(`${API_BASE}${path}`, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + return { status: res.status, json: await res.json() }; +} + +// ── Tests ────────────────────────────────────────────────── + +describe("Full user journey", () => { + let sessionToken: string; + let identityId: string; + let ticket: string; + const digest = randomBytes(32).toString("hex"); + + it("health check", async () => { + const res = await fetch(`${API_BASE}/health`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true, service: "api" }); + }); + + it("POST /v1/auth/challenge — create challenge (test mode auto-resolves)", async () => { + const { status, json } = await post("/v1/auth/challenge", { + telegram_user_id: TELEGRAM_USER_ID, + }); + expect(status).toBe(201); + expect(json.challenge_id).toBeDefined(); + expect(json.expires_at).toBeDefined(); + expect(json.deep_link).toBeNull(); // no bot configured in test mode + }); + + it("POST /v1/auth/verify — get session token", async () => { + // First create a challenge (auto-resolved in test mode) + const challenge = await post("/v1/auth/challenge", { + telegram_user_id: TELEGRAM_USER_ID, + }); + expect(challenge.status).toBe(201); + + const { status, json } = await post("/v1/auth/verify", { + challenge_id: challenge.json.challenge_id, + }); + expect(status).toBe(200); + expect(json.token).toBeDefined(); + expect(json.expires_in).toBeGreaterThan(0); + expect(json.user).toBeDefined(); + expect(json.user.telegram_user_id).toBe(TELEGRAM_USER_ID); + expect(json.user.status).toBe("active"); + sessionToken = json.token; + }); + + it("POST /v1/auth/verify — unresolved challenge returns 202", async () => { + // Create challenge without telegram_user_id (won't auto-resolve) + const challenge = await post("/v1/auth/challenge", {}); + expect(challenge.status).toBe(201); + + const { status } = await post("/v1/auth/verify", { + challenge_id: challenge.json.challenge_id, + }); + expect(status).toBe(202); + }); + + it("POST /v1/identities — create identity", async () => { + const { status, json } = await post( + "/v1/identities", + { alg: "secp256k1" }, + sessionToken + ); + expect(status).toBe(201); + expect(json.id).toBeDefined(); + expect(json.public_key).toBeDefined(); + expect(json.alg).toBe("secp256k1"); + expect(json.status).toBe("active"); + identityId = json.id; + }); + + it("POST /v1/identities/:id/sign-intent — get ticket", async () => { + const { status, json } = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest, scope: "sign" }, + sessionToken + ); + expect(status).toBe(201); + expect(json.ticket).toBeDefined(); + expect(json.nonce).toBeDefined(); + ticket = json.ticket; + }); + + it("POST /v1/identities/:id/sign — sign digest", async () => { + const { status, json } = await post( + `/v1/identities/${identityId}/sign`, + { digest, ticket }, + sessionToken + ); + expect(status).toBe(200); + expect(json.signature).toBeDefined(); + expect(typeof json.signature).toBe("string"); + expect(json.signature.length).toBeGreaterThan(0); + }); + + it("POST /v1/identities/:id/sign — replay ticket returns 409", async () => { + const { status, json } = await post( + `/v1/identities/${identityId}/sign`, + { digest, ticket }, + sessionToken + ); + expect(status).toBe(409); + expect(json.error).toBeDefined(); + }); + + it("GET /v1/audit — verify audit trail", async () => { + const { status, json } = await get("/v1/audit", sessionToken); + expect(status).toBe(200); + expect(json.items.length).toBeGreaterThanOrEqual(4); + const actions = json.items.map((e: { action: string }) => e.action); + expect(actions).toContain("user.create"); + expect(actions).toContain("session.create"); + expect(actions).toContain("identity.create"); + expect(actions).toContain("identity.sign"); + }); + + it("DELETE /v1/identities/:id — destroy identity", async () => { + const { status, json } = await del( + `/v1/identities/${identityId}`, + sessionToken + ); + expect(status).toBe(200); + expect(json.ok).toBe(true); + }); + + it("POST /v1/identities/:id/sign-intent — destroyed identity returns 409", async () => { + const { status } = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest: randomBytes(32).toString("hex"), scope: "sign" }, + sessionToken + ); + expect(status).toBe(409); + }); +}); + +describe("Sealed backup export / import", () => { + let sessionToken: string; + let identityId: string; + let sealedKey: string; + + it("setup: authenticate", async () => { + const challenge = await post("/v1/auth/challenge", { + telegram_user_id: "seal_test_user_" + Date.now(), + }); + const { json } = await post("/v1/auth/verify", { + challenge_id: challenge.json.challenge_id, + }); + sessionToken = json.token; + }); + + it("enclave export returns sealed_key, not private_key", async () => { + const { json } = await post( + "/v1/identities", + { alg: "secp256k1" }, + sessionToken + ); + identityId = json.id; + + // Export sealed key directly from enclave + const exportRes = await fetch(`${ENCLAVE_BASE}/internal/backup/export`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-internal-api-key": INTERNAL_API_KEY, + }, + body: JSON.stringify({ identity_id: identityId }), + }); + expect(exportRes.status).toBe(200); + const backup = await exportRes.json(); + + expect(backup.sealed_key).toBeDefined(); + expect(typeof backup.sealed_key).toBe("string"); + expect(backup.sealed_key.length).toBeGreaterThan(0); + expect(backup.alg).toBe("secp256k1"); + // Must NOT contain a raw private key + expect(backup).not.toHaveProperty("private_key"); + // Sealed key should be in AES format (iv:ciphertext:tag) for local dev + expect(backup.sealed_key.split(":").length).toBe(3); + sealedKey = backup.sealed_key; + }); + + it("backup restore works: sign after enclave key is destroyed internally", async () => { + // Destroy key directly in enclave (simulates enclave restart) + const destroyRes = await fetch(`${ENCLAVE_BASE}/internal/destroy`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-internal-api-key": INTERNAL_API_KEY, + }, + body: JSON.stringify({ identity_id: identityId }), + }); + expect(destroyRes.status).toBe(200); + + // Now sign through the API — should auto-restore from sealed backup (stored in D1) + const digest = randomBytes(32).toString("hex"); + const intentRes = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest, scope: "sign" }, + sessionToken + ); + expect(intentRes.status).toBe(201); + + const signRes = await post( + `/v1/identities/${identityId}/sign`, + { digest, ticket: intentRes.json.ticket }, + sessionToken + ); + expect(signRes.status).toBe(200); + expect(signRes.json.signature).toBeDefined(); + }); + + it("tampered sealed_key fails import", async () => { + // Destroy key in enclave first + await fetch(`${ENCLAVE_BASE}/internal/destroy`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-internal-api-key": INTERNAL_API_KEY, + }, + body: JSON.stringify({ identity_id: identityId }), + }); + + // Try importing a tampered sealed key directly + const importRes = await fetch(`${ENCLAVE_BASE}/internal/backup/import`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-internal-api-key": INTERNAL_API_KEY, + }, + body: JSON.stringify({ + identity_id: identityId, + alg: "secp256k1", + sealed_key: "aaaa:bbbb:cccc", + }), + }); + expect(importRes.status).toBe(500); + }); + + it("cleanup: destroy identity", async () => { + // Restore key in enclave so API destroy can wipe it + await fetch(`${ENCLAVE_BASE}/internal/backup/import`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-internal-api-key": INTERNAL_API_KEY, + }, + body: JSON.stringify({ + identity_id: identityId, + alg: "secp256k1", + sealed_key: sealedKey, + }), + }); + await del(`/v1/identities/${identityId}`, sessionToken); + }); +}); + +describe("Identity listing and recovery", () => { + let sessionToken: string; + let identityId: string; + let publicKey: string; + + it("setup: authenticate", async () => { + const challenge = await post("/v1/auth/challenge", { + telegram_user_id: "list_test_user_" + Date.now(), + }); + const { json } = await post("/v1/auth/verify", { + challenge_id: challenge.json.challenge_id, + }); + sessionToken = json.token; + }); + + it("GET /v1/identities — empty list for new user", async () => { + const { status, json } = await get("/v1/identities", sessionToken); + expect(status).toBe(200); + expect(json.items).toEqual([]); + }); + + it("GET /v1/identities — returns created identity", async () => { + const created = await post("/v1/identities", { alg: "secp256k1" }, sessionToken); + expect(created.status).toBe(201); + identityId = created.json.id; + publicKey = created.json.public_key; + + const { status, json } = await get("/v1/identities", sessionToken); + expect(status).toBe(200); + expect(json.items.length).toBe(1); + expect(json.items[0].id).toBe(identityId); + expect(json.items[0].public_key).toBe(publicKey); + expect(json.items[0].status).toBe("active"); + }); + + it("GET /v1/identities — excludes destroyed identities", async () => { + await del(`/v1/identities/${identityId}`, sessionToken); + const { status, json } = await get("/v1/identities", sessionToken); + expect(status).toBe(200); + expect(json.items).toEqual([]); + }); + + it("GET /v1/identities — multiple identities sorted by created_at DESC", async () => { + const first = await post("/v1/identities", { alg: "secp256k1" }, sessionToken); + const second = await post("/v1/identities", { alg: "secp256k1" }, sessionToken); + + const { status, json } = await get("/v1/identities", sessionToken); + expect(status).toBe(200); + expect(json.items.length).toBe(2); + // Most recent first + expect(json.items[0].id).toBe(second.json.id); + expect(json.items[1].id).toBe(first.json.id); + + // Cleanup + await del(`/v1/identities/${first.json.id}`, sessionToken); + await del(`/v1/identities/${second.json.id}`, sessionToken); + }); + + it("GET /v1/identities — requires auth", async () => { + const res = await fetch(`${API_BASE}/v1/identities`, { method: "GET" }); + expect(res.status).toBe(401); + }); + + it("recovery: restore identity via listing after config wipe", async () => { + // Create a fresh identity + const created = await post("/v1/identities", { alg: "secp256k1" }, sessionToken); + expect(created.status).toBe(201); + const recoveryId = created.json.id; + const recoveryPubKey = created.json.public_key; + + // Simulate config wipe: user only has session token, no identityId + // Step 1: List identities to discover existing ones + const listed = await get("/v1/identities", sessionToken); + expect(listed.json.items.length).toBeGreaterThan(0); + const found = listed.json.items[0]; + expect(found.id).toBe(recoveryId); + expect(found.public_key).toBe(recoveryPubKey); + + // Step 2: Restore the discovered identity + const restored = await post( + `/v1/identities/${found.id}/restore`, + { public_key: found.public_key }, + sessionToken + ); + expect(restored.status).toBe(200); + + // Step 3: Verify signing still works with the recovered identity + const digest = randomBytes(32).toString("hex"); + const intent = await post( + `/v1/identities/${found.id}/sign-intent`, + { digest, scope: "sign" }, + sessionToken + ); + expect(intent.status).toBe(201); + + const signed = await post( + `/v1/identities/${found.id}/sign`, + { digest, ticket: intent.json.ticket }, + sessionToken + ); + expect(signed.status).toBe(200); + expect(signed.json.signature).toBeDefined(); + + // Cleanup + await del(`/v1/identities/${recoveryId}`, sessionToken); + }); +}); + +describe("ECDSA signing", () => { + let sessionToken: string; + let identityId: string; + let publicKey: string; + + it("setup: authenticate and create identity", async () => { + const challenge = await post("/v1/auth/challenge", { + telegram_user_id: "ecdsa_test_user_" + Date.now(), + }); + const { json } = await post("/v1/auth/verify", { + challenge_id: challenge.json.challenge_id, + }); + sessionToken = json.token; + + const created = await post("/v1/identities", { alg: "secp256k1" }, sessionToken); + expect(created.status).toBe(201); + identityId = created.json.id; + publicKey = created.json.public_key; + }); + + it("sign-intent with signature_type=ecdsa returns ticket", async () => { + const digest = randomBytes(32).toString("hex"); + const { status, json } = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest, scope: "sign", signature_type: "ecdsa" }, + sessionToken + ); + expect(status).toBe(201); + expect(json.ticket).toBeDefined(); + expect(json.signature_type).toBe("ecdsa"); + }); + + it("sign with signature_type=ecdsa returns r, s, v", async () => { + const digest = randomBytes(32).toString("hex"); + const intent = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest, scope: "sign", signature_type: "ecdsa" }, + sessionToken + ); + expect(intent.status).toBe(201); + + const { status, json } = await post( + `/v1/identities/${identityId}/sign`, + { digest, ticket: intent.json.ticket, signature_type: "ecdsa" }, + sessionToken + ); + expect(status).toBe(200); + expect(json.signature).toBeDefined(); + expect(json.r).toBeDefined(); + expect(json.s).toBeDefined(); + expect(json.v).toBeDefined(); + // r and s should be 64 hex chars (32 bytes) + expect(json.r.length).toBe(64); + expect(json.s.length).toBe(64); + // v should be 27 or 28 (Ethereum convention) + expect([27, 28]).toContain(json.v); + // signature should be 130 hex chars (65 bytes: r + s + recovery) + expect(json.signature.length).toBe(130); + }); + + it("sign with default signature_type still returns Schnorr (backward compat)", async () => { + const digest = randomBytes(32).toString("hex"); + const intent = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest, scope: "sign" }, + sessionToken + ); + expect(intent.status).toBe(201); + + const { status, json } = await post( + `/v1/identities/${identityId}/sign`, + { digest, ticket: intent.json.ticket }, + sessionToken + ); + expect(status).toBe(200); + expect(json.signature).toBeDefined(); + // Schnorr signature is 128 hex chars (64 bytes) + expect(json.signature.length).toBe(128); + // Should NOT have r, s, v + expect(json.r).toBeUndefined(); + expect(json.s).toBeUndefined(); + expect(json.v).toBeUndefined(); + }); + + it("ECDSA signature recovers to correct public key", async () => { + // Use enclave's copy of @noble/secp256k1 since it's not a root dependency + const secp = await import("../enclave/node_modules/@noble/secp256k1/index.js") as any; + const { createHmac, createHash } = await import("node:crypto"); + + // Configure hashes for recovery + secp.hashes.hmacSha256 = (key: Uint8Array, ...msgs: Uint8Array[]) => { + const hmac = createHmac("sha256", key); + for (const msg of msgs) hmac.update(msg); + return new Uint8Array(hmac.digest()); + }; + secp.hashes.sha256 = (...msgs: Uint8Array[]) => { + const h = createHash("sha256"); + for (const msg of msgs) h.update(msg); + return new Uint8Array(h.digest()); + }; + + const digest = randomBytes(32).toString("hex"); + const intent = await post( + `/v1/identities/${identityId}/sign-intent`, + { digest, scope: "sign", signature_type: "ecdsa" }, + sessionToken + ); + const { json } = await post( + `/v1/identities/${identityId}/sign`, + { digest, ticket: intent.json.ticket, signature_type: "ecdsa" }, + sessionToken + ); + + // Recover public key from ECDSA signature (v3 API: signature first, then message) + const sigBytes = secp.etc.hexToBytes(json.signature); // 65 bytes: r(32) + s(32) + recovery(1) + const msgBytes = secp.etc.hexToBytes(digest); + const recovered = secp.recoverPublicKey(sigBytes, msgBytes, { prehash: false }); + const recoveredHex = secp.etc.bytesToHex(recovered); + + expect(recoveredHex).toBe(publicKey); + }); + + it("sign-batch with mixed signature types", async () => { + const digest1 = randomBytes(32).toString("hex"); + const digest2 = randomBytes(32).toString("hex"); + + const { status, json } = await post( + `/v1/identities/${identityId}/sign-batch`, + { + digests: [ + { digest: digest1, signature_type: "schnorr" }, + { digest: digest2, signature_type: "ecdsa" }, + ], + }, + sessionToken + ); + expect(status).toBe(200); + expect(json.signatures).toHaveLength(2); + + // First: Schnorr (64 bytes = 128 hex) + expect(json.signatures[0].signature.length).toBe(128); + expect(json.signatures[0].r).toBeUndefined(); + + // Second: ECDSA (65 bytes = 130 hex) + expect(json.signatures[1].signature.length).toBe(130); + expect(json.signatures[1].r).toBeDefined(); + expect(json.signatures[1].s).toBeDefined(); + expect([27, 28]).toContain(json.signatures[1].v); + }); + + it("cleanup: destroy identity", async () => { + await del(`/v1/identities/${identityId}`, sessionToken); + }); +}); + +describe("Auth guards", () => { + it("POST /v1/identities without token returns 401", async () => { + const { status } = await post("/v1/identities", { alg: "secp256k1" }); + expect(status).toBe(401); + }); + + it("POST /v1/identities with bad token returns 401", async () => { + const { status } = await post( + "/v1/identities", + { alg: "secp256k1" }, + "invalid-token" + ); + expect(status).toBe(401); + }); +}); diff --git a/test/evm-address.test.ts b/test/evm-address.test.ts new file mode 100644 index 0000000..79b6f03 --- /dev/null +++ b/test/evm-address.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { compressedPubKeyToEvmAddress } from "../remote-signer-identity/src/evm.js"; + +describe("compressedPubKeyToEvmAddress", () => { + it("derives correct EVM address from a known compressed public key", () => { + // Known test vector: private key = 1 + // Compressed pubkey for privkey=1: + // 0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 + const compressedPubKey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const address = compressedPubKeyToEvmAddress(compressedPubKey); + + // Known Ethereum address for privkey=1: + // 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf + expect(address.toLowerCase()).toBe("0x7e5f4552091a69125d5dfcb7b8c2659029395bdf"); + }); + + it("derives correct address for another known key", () => { + // Private key = 2 + // Compressed pubkey: + const compressedPubKey = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const address = compressedPubKeyToEvmAddress(compressedPubKey); + + // Known Ethereum address for privkey=2: + // 0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF + expect(address.toLowerCase()).toBe("0x2b5ad5c4795c026514f8317c7a215e218dccd6cf"); + }); + + it("returns checksummed-length address starting with 0x", () => { + const compressedPubKey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const address = compressedPubKeyToEvmAddress(compressedPubKey); + + expect(address).toMatch(/^0x[a-f0-9]{40}$/); + }); + + it("throws on invalid public key", () => { + expect(() => compressedPubKeyToEvmAddress("0000")).toThrow(); + }); +}); diff --git a/test/notifier.test.ts b/test/notifier.test.ts new file mode 100644 index 0000000..de7fdf3 --- /dev/null +++ b/test/notifier.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { WebhookRegistry, type SwapEvent, type SwapEventType } from "../cli/src/notifier.js"; + +function makeEvent(overrides?: Partial): SwapEvent { + return { + event: "swap.claimed", + swapId: "abc12345-dead-beef", + status: "completed", + direction: "btc_to_stablecoin", + sourceAmount: 100000, + sourceToken: "btc_arkade", + targetAmount: 10.5, + targetToken: "usdc_pol", + message: "Swap abc12345… claimed", + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +describe("WebhookRegistry", () => { + let registry: WebhookRegistry; + + beforeEach(() => { + registry = new WebhookRegistry(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers a webhook and returns id, url, events", () => { + const reg = registry.register("https://example.com/hook", ["swap.claimed"]); + expect(reg.id).toBeDefined(); + expect(reg.url).toBe("https://example.com/hook"); + expect(reg.events).toEqual(["swap.claimed"]); + }); + + it("filters out invalid events", () => { + const reg = registry.register("https://example.com/hook", [ + "swap.claimed", + "swap.invalid" as SwapEventType, + ]); + expect(reg.events).toEqual(["swap.claimed"]); + }); + + it("throws when no valid events provided", () => { + expect(() => + registry.register("https://example.com/hook", ["bad" as SwapEventType]) + ).toThrow("No valid events"); + }); + + it("lists registered webhooks", () => { + registry.register("https://a.com", ["swap.claimed"]); + registry.register("https://b.com", ["swap.refunded"]); + expect(registry.list()).toHaveLength(2); + }); + + it("unregisters a webhook by id", () => { + const reg = registry.register("https://a.com", ["swap.claimed"]); + expect(registry.unregister(reg.id)).toBe(true); + expect(registry.list()).toHaveLength(0); + }); + + it("returns false when unregistering unknown id", () => { + expect(registry.unregister("nonexistent")).toBe(false); + }); + + it("dispatches event to matching webhooks", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + registry.register("https://a.com/hook", ["swap.claimed"]); + registry.register("https://b.com/hook", ["swap.refunded"]); + + const event = makeEvent({ event: "swap.claimed" }); + registry.dispatch(event); + + // Let promises settle + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://a.com/hook", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(event), + }) + ); + }); + + it("dispatches to multiple matching webhooks", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + registry.register("https://a.com/hook", ["swap.claimed"]); + registry.register("https://b.com/hook", ["swap.claimed", "swap.failed"]); + + registry.dispatch(makeEvent({ event: "swap.claimed" })); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + it("does not dispatch to non-matching webhooks", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + registry.register("https://a.com/hook", ["swap.refunded"]); + + registry.dispatch(makeEvent({ event: "swap.claimed" })); + + // Give time for any potential calls + await new Promise((r) => setTimeout(r, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("logs error but does not throw when webhook fetch fails", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("network down")); + vi.stubGlobal("fetch", fetchMock); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + registry.register("https://a.com/hook", ["swap.failed"]); + registry.dispatch(makeEvent({ event: "swap.failed" })); + + await vi.waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("network down") + ); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..eea655a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + hookTimeout: 30_000, + include: ["test/**/*.test.ts"], + }, +}); diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..477ecca --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "@clw-cash/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@lendasat/lendaswap-sdk-pure": "^0.0.2", + "viem": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vite": "^6.0.0" + } +} diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..ec71171 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,5 @@ +/// + +interface Window { + ethereum?: import("viem").EIP1193Provider; +} diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..1e54297 --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,16 @@ + + + + + + Arkade Pay + + + + + + +
+ + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..130e3c9 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,157 @@ +import { createPublicClient, http, erc20Abi, parseUnits } from "viem"; +import { mainnet } from "viem/chains"; +import { parseParams, CURRENCY_CHAIN_TO_TOKEN, TOKEN_DECIMALS } from "./params.js"; +import { connectWallet } from "./wallet.js"; +import { createSwap, getFundingCallData, pollSwapStatus } from "./swap.js"; +import { renderPage, setStep, setStatus, setEnsName, setSender, selectChain, showError, updateDebug } from "./ui.js"; +import "./style.css"; + +async function main() { + try { + // Show loading while fetching swap data (for short URLs) + setStep("loading"); + const params = await parseParams(); + renderPage(params); + + // If swap is already funded/completed/expired, show status and stop + if (params.status !== "pending" && params.status !== "awaiting_funding") { + const FUNDED = new Set([ + "clientfundingseen", "clientfunded", "serverfunded", "processing", + "clientredeemed", "serverredeemed", "clientredeemedandclientrefunded", "completed", + ]); + const EXPIRED = new Set(["expired", "clientfundedtoolate", "failed"]); + const REFUNDED = new Set(["clientrefunded", "clientfundedserverrefunded", "clientrefundedserverfunded", "clientrefundedserverrefunded"]); + + if (FUNDED.has(params.status)) { + setStep("already-paid"); + } else if (EXPIRED.has(params.status) || REFUNDED.has(params.status)) { + setStep("link-expired"); + } else { + setStep("link-expired"); + } + return; + } + + // Background ENS reverse lookup (non-blocking) + if (params.to.startsWith("0x")) { + const ensClient = createPublicClient({ chain: mainnet, transport: http() }); + ensClient + .getEnsName({ address: params.to as `0x${string}` }) + .then((name) => { + if (name) setEnsName(name); + }) + .catch(() => { + /* ENS resolution is best-effort */ + }); + } + + // Chain selection: sender picks which chain to pay on + let selectedChain = params.chain; + let selectedToken = params.token; + + if (params.needsChainSelection) { + document.querySelectorAll(".chain-option").forEach((btn) => { + btn.addEventListener("click", () => { + const chain = (btn as HTMLElement).dataset.chain!; + const tokenMap = CURRENCY_CHAIN_TO_TOKEN[params.currency!]; + if (!tokenMap) return; + selectedChain = chain; + selectedToken = tokenMap[chain]; + selectChain(chain, selectedToken); + }); + }); + } + + document.getElementById("connect-btn")!.addEventListener("click", async () => { + try { + // 1. Connect wallet + setStep("connecting"); + const { walletClient, publicClient, address } = await connectWallet(selectedChain); + setSender(address); + updateDebug({ sender: address, chain: selectedChain, token: selectedToken }); + + let approveTo: string; + let approveData: string; + let fundTo: string; + let fundData: string; + let swapId: string | undefined; + + if (params.funding) { + // Pre-created by CLI — funding call data from API or URL + approveTo = params.funding.approveTo; + approveData = params.funding.approveData; + fundTo = params.funding.fundTo; + fundData = params.funding.fundData; + swapId = params.swapId; + } else { + // Web creates the swap (chain selected by sender or from URL) + setStep("creating-swap"); + const swapParams = { ...params, chain: selectedChain, token: selectedToken }; + const swapResult = await createSwap(swapParams, address); + swapId = swapResult.response.id; + updateDebug({ swapId }); + + setStep("preparing-tx"); + const callData = await getFundingCallData(swapId, selectedToken); + approveTo = callData.approve.to; + approveData = callData.approve.data; + fundTo = callData.createSwap.to; + fundData = callData.createSwap.data; + } + + // 2. Approve token spend (skip if allowance already sufficient) + const requiredAmount = parseUnits(String(params.amount), TOKEN_DECIMALS[selectedToken] ?? 6); + const currentAllowance = await publicClient.readContract({ + address: approveTo as `0x${string}`, + abi: erc20Abi, + functionName: "allowance", + args: [address, fundTo as `0x${string}`], + }); + updateDebug({ currentAllowance: currentAllowance.toString(), requiredAmount: requiredAmount.toString() }); + + if (currentAllowance < requiredAmount) { + setStep("approve"); + const approveTxHash = await walletClient.sendTransaction({ + to: approveTo as `0x${string}`, + data: approveData as `0x${string}`, + account: address, + chain: walletClient.chain, + }); + await publicClient.waitForTransactionReceipt({ hash: approveTxHash }); + updateDebug({ approveTx: approveTxHash }); + } else { + updateDebug({ approveSkipped: true }); + } + + // 3. Fund the swap + setStep("fund"); + const fundTxHash = await walletClient.sendTransaction({ + to: fundTo as `0x${string}`, + data: fundData as `0x${string}`, + account: address, + chain: walletClient.chain, + }); + await publicClient.waitForTransactionReceipt({ hash: fundTxHash }); + updateDebug({ fundTx: fundTxHash }); + + // 4. Done — poll LendaSwap for completion + if (params.funding) { + // Pre-created swap: CLI daemon claims automatically + setStep("done"); + } else if (swapId) { + setStep("waiting"); + const success = await pollSwapStatus(swapId, (status) => { + setStatus(status); + }); + setStep(success ? "done" : "failed"); + } + } catch (err) { + showError(err instanceof Error ? err.message : "Something went wrong"); + } + }); + } catch (err) { + showError(err instanceof Error ? err.message : "Invalid payment link"); + } +} + +main(); diff --git a/web/src/params.ts b/web/src/params.ts new file mode 100644 index 0000000..fbe4bd9 --- /dev/null +++ b/web/src/params.ts @@ -0,0 +1,238 @@ +import { encodeFunctionData, maxUint256 } from "viem"; + +export interface FundingCallData { + approveTo: string; + approveData: string; + fundTo: string; + fundData: string; +} + +export interface PaymentParams { + amount: number; + token: string; + chain: string; + to: string; + /** Swap status from LendaSwap API */ + status: string; + /** When present, swap was pre-created by CLI — web only funds it */ + swapId?: string; + /** Pre-computed EVM funding call data */ + funding?: FundingCallData; + /** When true, sender must pick the chain — token/chain are empty until selected */ + needsChainSelection?: boolean; + /** Stablecoin currency (usdc or usdt) for chain selection flow */ + currency?: string; +} + +const VALID_TOKENS = new Set([ + "usdc_pol", "usdc_eth", "usdc_arb", + "usdt0_pol", "usdt_eth", "usdt_arb", +]); + +const VALID_CHAINS = new Set(["polygon", "ethereum", "arbitrum"]); + +export const CHAIN_IDS: Record = { + polygon: 137, + ethereum: 1, + arbitrum: 42161, +}; + +export const TOKEN_DECIMALS: Record = { + usdc_pol: 6, usdc_eth: 6, usdc_arb: 6, + usdt0_pol: 6, usdt_eth: 6, usdt_arb: 6, +}; + +export const TOKEN_LABELS: Record = { + usdc_pol: "USDC", usdc_eth: "USDC", usdc_arb: "USDC", + usdt0_pol: "USDT0", usdt_eth: "USDT", usdt_arb: "USDT", +}; + +export const CHAIN_LABELS: Record = { + polygon: "Polygon", + ethereum: "Ethereum", + arbitrum: "Arbitrum", +}; + +export const CHAIN_COLORS: Record = { + polygon: "#8247e5", + ethereum: "#627eea", + arbitrum: "#28a0f0", +}; + +export const TOKEN_COLORS: Record = { + usdc_pol: "#2775ca", usdc_eth: "#2775ca", usdc_arb: "#2775ca", + usdt0_pol: "#26a17b", usdt_eth: "#26a17b", usdt_arb: "#26a17b", +}; + +// Official token logos as inline SVGs (20x20) +const USDC_LOGO = ``; + +const USDT_LOGO = ``; + +// USDT0 uses same Tether branding with a "0" badge concept — same green logo +const USDT0_LOGO = USDT_LOGO; + +/** Map token ID → logo SVG */ +export const TOKEN_LOGOS: Record = { + usdc_pol: USDC_LOGO, usdc_eth: USDC_LOGO, usdc_arb: USDC_LOGO, + usdt0_pol: USDT0_LOGO, usdt_eth: USDT_LOGO, usdt_arb: USDT_LOGO, +}; + +/** Map currency name → logo SVG (for chain picker flow before token is resolved) */ +export const CURRENCY_LOGOS: Record = { + usdc: USDC_LOGO, + usdt: USDT_LOGO, +}; + +/** Derive chain name from LendaSwap token ID (e.g. usdc_pol → polygon) */ +const TOKEN_TO_CHAIN: Record = { + usdc_pol: "polygon", usdc_eth: "ethereum", usdc_arb: "arbitrum", + usdt0_pol: "polygon", usdt_eth: "ethereum", usdt_arb: "arbitrum", +}; + +/** All supported chains for sender-side chain selection */ +export const SUPPORTED_CHAINS = ["polygon", "arbitrum", "ethereum"] as const; + +/** Map currency + chain → LendaSwap token ID */ +export const CURRENCY_CHAIN_TO_TOKEN: Record> = { + usdc: { polygon: "usdc_pol", ethereum: "usdc_eth", arbitrum: "usdc_arb" }, + usdt: { polygon: "usdt0_pol", ethereum: "usdt_eth", arbitrum: "usdt_arb" }, +}; + +/** Statuses where funding has NOT yet happened — safe to show pay button */ +const FUNDABLE_STATUSES = new Set([ + "pending", + "awaiting_funding", +]); + +/** Statuses that indicate the swap is already funded or completed */ +const FUNDED_STATUSES = new Set([ + "clientfundingseen", + "clientfunded", + "serverfunded", + "processing", + "clientredeemed", + "serverredeemed", + "clientredeemedandclientrefunded", + "completed", +]); + +const ERC20_APPROVE_ABI = [ + { + name: "approve", + type: "function", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ type: "bool" }], + }, +] as const; + +function buildApproveCallData(tokenAddress: string, spender: string): { to: string; data: string } { + const data = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [spender as `0x${string}`, maxUint256], + }); + return { to: tokenAddress, data }; +} + +export async function parseParams(): Promise { + const url = new URL(window.location.href); + + // New short URL: /pay?id= — fetch everything from API proxy + const id = url.searchParams.get("id"); + if (id) { + const apiUrl = import.meta.env.VITE_API_URL || ""; + const resp = await fetch(`${apiUrl}/v1/swaps/${encodeURIComponent(id)}`); + if (!resp.ok) { + throw new Error( + resp.status === 404 + ? "Payment link not found or expired" + : `Failed to load payment data (${resp.status})` + ); + } + const swap = await resp.json(); + + const token = swap.source_token as string; + const chain = TOKEN_TO_CHAIN[token] ?? ""; + const status = swap.status as string; + + // Build funding call data from swap response + let funding: FundingCallData | undefined; + if (FUNDABLE_STATUSES.has(status) && swap.create_swap_tx && swap.htlc_address_evm && swap.source_token_address) { + const approve = buildApproveCallData(swap.source_token_address, swap.htlc_address_evm); + funding = { + approveTo: approve.to, + approveData: approve.data, + fundTo: swap.htlc_address_evm, + fundData: swap.create_swap_tx, + }; + } + + return { + amount: swap.source_amount, + token, + chain, + to: swap.htlc_address_arkade ?? swap.target_address ?? "", + status, + swapId: swap.id, + funding, + }; + } + + // Legacy: swapId with inline call data in URL (backward compat) + const swapId = url.searchParams.get("swapId") ?? undefined; + if (swapId) { + const amount = parseFloat(url.searchParams.get("amount") ?? "0"); + const token = url.searchParams.get("token") ?? ""; + const chain = url.searchParams.get("chain") ?? ""; + const to = url.searchParams.get("to") ?? ""; + + const approveTo = url.searchParams.get("approveTo") ?? ""; + const approveData = url.searchParams.get("approveData") ?? ""; + const fundTo = url.searchParams.get("fundTo") ?? ""; + const fundData = url.searchParams.get("fundData") ?? ""; + + if (!approveTo || !approveData || !fundTo || !fundData) { + throw new Error("Missing funding call data in payment link"); + } + + return { + amount, token, chain, to, swapId, status: "pending", + funding: { approveTo, approveData, fundTo, fundData }, + }; + } + + // Chain-selection flow: /pay?amount=10&to=¤cy=usdc + // Sender picks the chain on the web page + const currency = url.searchParams.get("currency") ?? ""; + const amount = parseFloat(url.searchParams.get("amount") ?? ""); + const to = url.searchParams.get("to") ?? ""; + + if (currency && (currency === "usdc" || currency === "usdt")) { + if (isNaN(amount) || amount <= 0) throw new Error("Invalid or missing amount"); + if (!to || to.length < 10) throw new Error("Missing target address"); + return { + amount, + token: "", + chain: "", + to, + status: "pending", + needsChainSelection: true, + currency, + }; + } + + // Legacy: full params in URL (web creates swap) + const token = url.searchParams.get("token") ?? ""; + const chain = url.searchParams.get("chain") ?? ""; + + if (isNaN(amount) || amount <= 0) throw new Error("Invalid or missing amount"); + if (!VALID_TOKENS.has(token)) throw new Error(`Invalid token: ${token}`); + if (!VALID_CHAINS.has(chain)) throw new Error(`Invalid chain: ${chain}`); + if (!to || to.length < 10) throw new Error("Missing target address"); + + return { amount, token, chain, to, status: "pending" }; +} diff --git a/web/src/public/_redirects b/web/src/public/_redirects new file mode 100644 index 0000000..ad37e2c --- /dev/null +++ b/web/src/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..4ec7df5 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,604 @@ +/* ── Cash for Claws ── */ + +:root { + --dark: #111; + --mid: #666; + --light: #aaa; + --faint: #e8e8e8; + --bg: #f5f5f5; + --card: #fff; + --surface: #fafafa; + --accent: #f14317; + --accent-soft: rgba(241, 67, 23, 0.07); + + --font: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: "Geist Mono", ui-monospace, SFMono-Regular, monospace; + + --r-card: 16px; + --r-btn: 12px; + --r-sm: 8px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html { height: 100%; } + +body { + font-family: var(--font); + background: var(--bg); + color: var(--dark); + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + -webkit-font-smoothing: antialiased; +} + +#app { + width: 100%; + max-width: 400px; + padding: 16px; +} + +/* ── Card ── */ + +.checkout-card { + background: var(--card); + border-radius: var(--r-card); + padding: 32px 28px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 24px rgba(0,0,0,0.06); +} + +/* ── Brand ── */ + +.brand-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 32px; +} + +.brand-name { + font-size: 15px; + font-weight: 700; + color: var(--dark); + letter-spacing: -0.3px; +} + +.brand-tagline { + font-size: 12px; + color: var(--light); + margin-top: 2px; +} + +.brand-tagline em { + font-style: normal; + color: var(--mid); +} + +/* ── Amount ── */ + +.amount-section { + text-align: center; + margin-bottom: 28px; +} + +.amount-value { + font-size: 56px; + font-weight: 700; + color: var(--dark); + letter-spacing: -3px; + line-height: 1; +} + +.amount-currency { + font-size: 24px; + font-weight: 600; + color: var(--light); + letter-spacing: -0.5px; + vertical-align: super; + margin-left: 4px; +} + +.amount-meta { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 13px; + color: var(--mid); +} + +.token-dot, .chain-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.token-logo { + display: inline-flex; + align-items: center; + flex-shrink: 0; + line-height: 0; +} + +.amount-value .token-logo svg { + width: 36px; + height: 36px; +} + +.amount-meta .token-logo svg { + width: 16px; + height: 16px; +} + +.meta-sep { + color: var(--faint); +} + +/* ── Chain Picker ── */ + +.chain-picker { + margin-bottom: 24px; +} + +.chain-picker-label { + font-size: 11px; + font-weight: 600; + color: var(--light); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 8px; +} + +.chain-picker-options { + display: flex; + gap: 8px; +} + +.chain-option { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 8px; + background: var(--card); + border: 1.5px solid var(--faint); + border-radius: var(--r-sm); + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--font); + font-size: 13px; + font-weight: 500; + color: var(--mid); +} + +.chain-option:hover { + border-color: var(--light); + color: var(--dark); +} + +.chain-option.selected { + border-color: var(--dark); + color: var(--dark); + font-weight: 600; +} + +/* ── Recipient ── */ + +.recipient-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--faint); + border-radius: var(--r-sm); + margin-bottom: 24px; +} + +.recipient-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.recipient-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--dark); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; +} + +.recipient-info { + min-width: 0; +} + +.recipient-label { + font-size: 11px; + color: var(--light); + margin-bottom: 1px; +} + +.recipient-address { + font-family: var(--mono); + font-size: 12px; + color: var(--mid); +} + +.recipient-ens { + font-size: 13px; + font-weight: 600; + color: var(--dark); + margin-bottom: 1px; +} + +.copy-btn { + background: none; + border: 1px solid var(--faint); + color: var(--light); + cursor: pointer; + padding: 6px; + border-radius: var(--r-sm); + transition: all 0.15s ease; + flex-shrink: 0; + display: flex; +} + +.copy-btn:hover { + color: var(--mid); + border-color: var(--light); +} + +.copy-btn.copied { + color: var(--dark); + border-color: var(--dark); +} + +/* ── Sender row (appears after connect) ── */ + +.sender-section { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--faint); + border-radius: var(--r-sm); + margin-bottom: 8px; +} + +.sender-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--card); + border: 1.5px solid var(--faint); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; +} + +.transfer-arrow { + text-align: center; + color: var(--light); + padding: 4px 0; + margin-bottom: 8px; +} + +/* ── Action Button ── */ + +.action-area { + margin-bottom: 20px; +} + +.action-btn { + width: 100%; + padding: 14px; + font-size: 14px; + font-weight: 600; + font-family: var(--font); + color: #fff; + background: var(--dark); + border: none; + border-radius: var(--r-btn); + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + letter-spacing: -0.2px; +} + +.action-btn:hover:not(:disabled) { + background: #000; +} + +.action-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Status & Progress ── */ + +.status-area { + text-align: center; + margin-bottom: 20px; + animation: fadeIn 0.2s ease; +} + +.status-title { + font-size: 14px; + font-weight: 600; + color: var(--dark); + margin-bottom: 2px; +} + +.status-desc { + font-size: 12px; + color: var(--light); +} + +/* ── Result (terminal states) ── */ + +.result-area { + text-align: center; + padding: 8px 0; + animation: fadeIn 0.3s ease; +} + +.result-icon { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 12px; + font-size: 20px; + font-weight: 700; +} + +.result-icon.success { + background: var(--accent-soft); + color: var(--accent); +} + +.result-icon.success svg { + width: 20px; + height: 20px; +} + +.result-icon.muted { + background: var(--surface); + border: 1px solid var(--faint); + color: var(--light); +} + +.result-title { + font-size: 18px; + font-weight: 700; + color: var(--dark); + margin-bottom: 4px; + letter-spacing: -0.3px; +} + +.result-desc { + font-size: 13px; + color: var(--light); +} + +/* ── Explainer ── */ + +.explainer { + background: var(--surface); + border: 1px solid var(--faint); + border-radius: var(--r-sm); + padding: 12px 14px; + margin-bottom: 20px; + display: flex; + gap: 10px; + align-items: flex-start; + animation: fadeSlideIn 0.2s ease; +} + +.explainer-icon { + font-size: 14px; + line-height: 1; + flex-shrink: 0; + margin-top: 1px; +} + +.explainer-title { + font-size: 12px; + font-weight: 600; + color: var(--dark); + margin-bottom: 2px; +} + +.explainer-text { + font-size: 12px; + color: var(--mid); + line-height: 1.45; +} + +/* ── Error ── */ + +.error-box { + background: var(--surface); + border: 1px solid var(--faint); + border-radius: var(--r-sm); + padding: 12px 14px; + margin-top: 12px; +} + +.error-text { + color: var(--mid); + font-size: 13px; +} + +/* ── Spinner ── */ + +.spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; + flex-shrink: 0; +} + +/* ── Progress bar ── */ + +.progress-bar { + height: 3px; + background: var(--faint); + border-radius: 2px; + margin-bottom: 20px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--dark); + border-radius: 2px; + width: 0%; + animation: progressPulse 2s ease-in-out infinite; +} + +.progress-fill.animating { + transition: width 21s cubic-bezier(0.1, 0.4, 0.2, 1); +} + +.progress-fill.instant { + transition: width 0.4s ease; +} + +@keyframes progressPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Raw Status ── */ + +.raw-status { + font-size: 11px; + color: var(--light); + font-family: var(--mono); + text-align: center; + margin-top: 4px; +} + +/* ── Page Footer (outside card) ── */ + +.page-footer { + text-align: center; + padding: 20px 0 8px; + position: relative; + z-index: 10; +} + +.footer-powered { + font-size: 12px; + color: var(--mid); + letter-spacing: 0.2px; +} + +.footer-arkade { + color: var(--dark); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--light); + transition: all 0.15s ease; + cursor: pointer; + padding: 6px 8px; + margin: -6px -8px; + border-radius: 6px; +} + +.footer-arkade:hover { + color: #000; + text-decoration-color: var(--dark); + background: rgba(0,0,0,0.05); +} + +/* ── Debug ── */ + +.debug-toggle { + background: none; + border: none; + color: var(--faint); + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + transition: color 0.15s ease; + flex-shrink: 0; +} + +.debug-toggle:hover { + color: var(--light); +} + +.debug-panel { + margin-top: 8px; + background: var(--dark); + border-radius: var(--r-sm); + padding: 12px; + text-align: left; + animation: fadeSlideIn 0.15s ease; +} + +.debug-title { + font-size: 10px; + font-weight: 600; + color: var(--light); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 8px; +} + +.debug-content { + font-family: var(--mono); + font-size: 10px; + line-height: 1.5; + color: var(--light); + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +/* ── Animations ── */ + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes fadeSlideIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/web/src/swap.ts b/web/src/swap.ts new file mode 100644 index 0000000..ad146df --- /dev/null +++ b/web/src/swap.ts @@ -0,0 +1,76 @@ +import { + Client, + IdbWalletStorage, + IdbSwapStorage, +} from "@lendasat/lendaswap-sdk-pure"; +import type { PaymentParams } from "./params.js"; +import { TOKEN_DECIMALS } from "./params.js"; + +let client: Client | null = null; + +async function getClient(): Promise { + if (client) return client; + client = await Client.builder() + .withSignerStorage(new IdbWalletStorage()) + .withSwapStorage(new IdbSwapStorage()) + .build(); + return client; +} + +/** + * Create a new swap (legacy flow — only used when swapId is not provided). + */ +export async function createSwap(params: PaymentParams, userAddress: string) { + const c = await getClient(); + const result = await c.createEvmToArkadeSwap({ + sourceChain: params.chain, + sourceToken: params.token, + sourceAmount: params.amount, + targetAddress: params.to, + userAddress, + }); + return result; +} + +export async function getFundingCallData(swapId: string, token: string) { + const c = await getClient(); + const decimals = TOKEN_DECIMALS[token] ?? 6; + return c.getEvmFundingCallData(swapId, decimals); +} + +const TERMINAL_STATUSES = new Set([ + "clientredeemed", + "serverredeemed", + "clientredeemedandclientrefunded", + "expired", + "clientfundedtoolate", + "clientrefunded", + "clientfundedserverrefunded", + "clientrefundedserverfunded", + "clientrefundedserverrefunded", + "clientinvalidfunded", +]); + +const SUCCESS_STATUSES = new Set([ + "clientredeemed", + "serverredeemed", + "clientredeemedandclientrefunded", +]); + +export async function pollSwapStatus( + swapId: string, + onUpdate: (status: string, isSuccess: boolean) => void, + intervalMs = 3000, + maxAttempts = 200 +): Promise { + const c = await getClient(); + + for (let i = 0; i < maxAttempts; i++) { + const data = await c.getSwap(swapId, { updateStorage: true }); + const isSuccess = SUCCESS_STATUSES.has(data.status); + onUpdate(data.status, isSuccess); + if (TERMINAL_STATUSES.has(data.status)) return isSuccess; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("Swap polling timed out"); +} diff --git a/web/src/ui.ts b/web/src/ui.ts new file mode 100644 index 0000000..b058e65 --- /dev/null +++ b/web/src/ui.ts @@ -0,0 +1,419 @@ +import type { PaymentParams } from "./params.js"; +import { TOKEN_LABELS, CHAIN_LABELS, CHAIN_COLORS, TOKEN_COLORS, SUPPORTED_CHAINS, TOKEN_LOGOS, CURRENCY_LOGOS } from "./params.js"; + +// ── Icons ── + +const COPY_SVG = ``; +const CHECK_SVG = ``; +const ARROW_SVG = ``; +const INFO_SVG = ``; + +const ARKADE_LOGO_SM = ` + + + + +`; + +// ── Step Config ── + +interface StepInfo { + title: string; + desc: string; + progress: number; // 0-100 + explainer?: string; +} + +const STEPS: Record = { + loading: { title: "Loading\u2026", desc: "fetching payment details", progress: 0 }, + "already-paid": { title: "Already paid", desc: "This payment link has been used", progress: 100 }, + "link-expired": { title: "Link expired", desc: "This payment link is no longer valid", progress: 0 }, + connecting: { title: "Connecting\u2026", desc: "Approve in your wallet", progress: 15 }, + "creating-swap":{ title: "Preparing swap\u2026", desc: "Setting things up", progress: 30 }, + "preparing-tx": { title: "Building tx\u2026", desc: "Almost there", progress: 40 }, + approve: { title: "Step 1 of 2: Approve", desc: "Check your wallet", progress: 50, + explainer: "Your wallet will ask to approve token spending. This is a standard permission \u2014 no funds leave yet. A second confirmation will follow." }, + fund: { title: "Confirm payment", desc: "Last step", progress: 70, + explainer: "Your wallet will confirm the payment. This sends stablecoins to complete the swap to bitcoin." }, + waiting: { title: "Processing\u2026", desc: "Waiting for confirmation", progress: 85 }, + done: { title: "Sent!", desc: "Bitcoin is on its way", progress: 100 }, + failed: { title: "Failed", desc: "Swap expired or hit an error", progress: 100 }, +}; + +// ── Helpers ── + +function truncAddr(addr: string): string { + if (addr.length <= 14) return addr; + return `${addr.slice(0, 6)}\u2026${addr.slice(-4)}`; +} + +function tokenLogo(token: string): string { + const logo = TOKEN_LOGOS[token]; + if (logo) return ``; + const c = TOKEN_COLORS[token] ?? "#888"; + return ``; +} + +function currencyLogo(currency: string): string { + const logo = CURRENCY_LOGOS[currency]; + if (logo) return ``; + return ""; +} + +function chainDot(chain: string): string { + const c = CHAIN_COLORS[chain] ?? "#888"; + return ``; +} + +// ── Layout parts ── + +function brandHeader(): string { + return ` +
+
+
Cash for Claws
+
bots get bitcoins, humans pay in stablecoins
+
+ +
`; +} + +function debugPanelHtml(): string { + return ` + `; +} + +function pageFooter(): string { + return ` + `; +} + +function amountHtml(params: PaymentParams): string { + const cur = params.needsChainSelection + ? (params.currency ?? "").toUpperCase() + : (TOKEN_LABELS[params.token] ?? params.token); + + const logo = params.needsChainSelection + ? currencyLogo(params.currency ?? "") + : tokenLogo(params.token); + + let meta = ""; + if (!params.needsChainSelection && params.chain) { + const chainLabel = CHAIN_LABELS[params.chain] ?? params.chain; + meta = ` +
+ ${tokenLogo(params.token)} ${cur} + \u00B7 + ${chainDot(params.chain)} ${chainLabel} +
`; + } + + return ` +
+
${logo} ${params.amount}${cur}
+ ${meta} +
`; +} + +function chainPickerHtml(): string { + const items = SUPPORTED_CHAINS.map((c) => { + const color = CHAIN_COLORS[c] ?? "#888"; + return ` + `; + }).join(""); + + return ` +
+
Pay on
+
${items}
+
`; +} + +function recipientHtml(params: PaymentParams): string { + return ` +
+
+
\uD83E\uDD16
+
+
To
+ +
${truncAddr(params.to)}
+
+
+ +
`; +} + +// ── Render ── + +export function renderPage(params: PaymentParams) { + const showChainPicker = !!params.needsChainSelection; + + document.getElementById("app")!.innerHTML = ` +
+ ${brandHeader()} + ${amountHtml(params)} + ${showChainPicker ? chainPickerHtml() : ""} + ${recipientHtml(params)} + +
+
+
+ +
+
+
+ + ${debugPanelHtml()} +
+ ${pageFooter()} + `; + + // Copy handler + const addr = params.to; + document.getElementById("copy-btn")!.addEventListener("click", () => { + navigator.clipboard.writeText(addr).then(() => { + const btn = document.getElementById("copy-btn")!; + btn.innerHTML = CHECK_SVG; + btn.classList.add("copied"); + setTimeout(() => { btn.innerHTML = COPY_SVG; btn.classList.remove("copied"); }, 1500); + }); + }); + + // Debug toggle + document.getElementById("debug-toggle")!.addEventListener("click", () => { + const panel = document.getElementById("debug-panel")!; + panel.hidden = !panel.hidden; + }); + + // Initial debug data + updateDebug({ + swapId: params.swapId ?? null, + token: params.token || null, + chain: params.chain || null, + currency: params.currency ?? null, + amount: params.amount, + to: params.to, + status: params.status, + hasFunding: !!params.funding, + needsChainSelection: !!params.needsChainSelection, + url: window.location.href, + }); +} + +// ── Chain selection ── + +export function selectChain(chain: string, token: string) { + // Update amount meta + const section = document.querySelector(".amount-section"); + if (section) { + const existing = section.querySelector(".amount-meta"); + const chainLabel = CHAIN_LABELS[chain] ?? chain; + const tok = TOKEN_LABELS[token] ?? token; + const html = ` +
+ ${tokenLogo(token)} ${tok} + \u00B7 + ${chainDot(chain)} ${chainLabel} +
`; + if (existing) { + existing.outerHTML = html; + } else { + section.insertAdjacentHTML("beforeend", html); + } + } + + // Highlight selected + document.querySelectorAll(".chain-option").forEach((btn) => { + btn.classList.toggle("selected", (btn as HTMLElement).dataset.chain === chain); + }); + + // Enable button + const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement | null; + if (connectBtn) connectBtn.disabled = false; +} + +// ── Step updates ── + +export function setStep(step: string) { + const info = STEPS[step]; + if (!info) return; + + // Loading: before page renders + if (step === "loading") { + const app = document.getElementById("app"); + if (app && !document.querySelector(".checkout-card")) { + app.innerHTML = ` +
+ ${brandHeader()} +
+

${info.title}

+

${info.desc}

+
+ +
+ ${pageFooter()}`; + } + return; + } + + const card = document.querySelector(".checkout-card"); + if (!card) return; + + // Terminal states: hide amount + recipient, show only the result + const isTerminal = step === "already-paid" || step === "link-expired" || step === "failed"; + const amountEl = card.querySelector(".amount-section") as HTMLElement | null; + const recipientEl = document.getElementById("recipient-section") as HTMLElement | null; + const chainPicker = card.querySelector(".chain-picker") as HTMLElement | null; + if (amountEl) amountEl.style.display = isTerminal ? "none" : ""; + if (recipientEl) recipientEl.style.display = isTerminal ? "none" : ""; + if (chainPicker) chainPicker.style.display = isTerminal ? "none" : ""; + + // Progress bar — animate towards target over ~60s, pulsing while in progress + const progArea = document.getElementById("progress-area")!; + const shouldShow = info.progress > 0 && !isTerminal && step !== "done"; + if (shouldShow) { + let fill = progArea.querySelector(".progress-fill") as HTMLElement | null; + if (!fill) { + progArea.innerHTML = `
`; + fill = progArea.querySelector(".progress-fill") as HTMLElement; + } + // Start from 0 width, then animate to target on next frame + requestAnimationFrame(() => { + fill!.classList.remove("instant"); + fill!.classList.add("animating"); + fill!.style.width = `${info.progress}%`; + }); + } else if (step === "done") { + // Snap to 100% quickly on success + const fill = progArea.querySelector(".progress-fill") as HTMLElement | null; + if (fill) { + fill.classList.remove("animating"); + fill.classList.add("instant"); + fill.style.width = "100%"; + fill.style.animation = "none"; + // Remove bar after transition + setTimeout(() => { progArea.innerHTML = ""; }, 500); + } else { + progArea.innerHTML = ""; + } + } else { + progArea.innerHTML = ""; + } + + // Action area + const area = document.getElementById("action-area")!; + + if (step === "done") { + area.innerHTML = ` +
+
${CHECK_SVG}
+

${info.title}

+

${info.desc}

+
`; + } else if (step === "already-paid") { + area.innerHTML = ` +
+
${CHECK_SVG}
+

${info.title}

+

${info.desc}

+
`; + } else if (step === "failed" || step === "link-expired") { + area.innerHTML = ` +
+
!
+

${info.title}

+

${info.desc}

+
`; + } else { + area.innerHTML = ` +
+

${info.title}

+

${info.desc}

+
+ `; + } + + // Log to debug + updateDebug({ step, progress: info.progress }); + + // Explainer + const explArea = document.getElementById("explainer-area")!; + if (info.explainer) { + explArea.innerHTML = ` +
+ \uD83D\uDC46 +
+

Wallet popup incoming

+

${info.explainer}

+
+
`; + } else { + explArea.innerHTML = ""; + } +} + +export function setStatus(rawStatus: string) { + let el = document.getElementById("raw-status"); + if (!el) { + el = document.createElement("p"); + el.id = "raw-status"; + el.className = "raw-status"; + document.getElementById("action-area")!.appendChild(el); + } + el.textContent = rawStatus; +} + +export function setSender(address: string) { + const area = document.getElementById("sender-area")!; + area.innerHTML = ` +
+
\uD83D\uDC64
+
+
From
+
${truncAddr(address)}
+
+
+
${ARROW_SVG}
`; +} + +export function setEnsName(name: string) { + const el = document.getElementById("ens-name"); + if (el) { + el.textContent = name; + el.hidden = false; + } +} + +export function showError(msg: string) { + const area = document.getElementById("error-area"); + if (area) { + area.innerHTML = `

${msg}

`; + } else { + document.getElementById("app")!.innerHTML = ` +
+ ${brandHeader()} +

${msg}

+
+ ${pageFooter()}`; + } + updateDebug({ error: msg }); +} + +// ── Debug panel ── + +const debugState: Record = {}; + +export function updateDebug(data: Record) { + Object.assign(debugState, data); + const el = document.getElementById("debug-content"); + if (el) el.textContent = JSON.stringify(debugState, null, 2); +} diff --git a/web/src/wallet.ts b/web/src/wallet.ts new file mode 100644 index 0000000..102367e --- /dev/null +++ b/web/src/wallet.ts @@ -0,0 +1,55 @@ +import { + createWalletClient, + createPublicClient, + custom, + http, + type WalletClient, + type PublicClient, + type Chain, +} from "viem"; +import { polygon, mainnet, arbitrum } from "viem/chains"; + +const CHAINS: Record = { + polygon, + ethereum: mainnet, + arbitrum, +}; + +export async function connectWallet(chainName: string): Promise<{ + walletClient: WalletClient; + publicClient: PublicClient; + address: `0x${string}`; +}> { + if (!window.ethereum) { + throw new Error("No wallet detected. Please install MetaMask."); + } + + const chain = CHAINS[chainName]; + if (!chain) throw new Error(`Unsupported chain: ${chainName}`); + + const walletClient = createWalletClient({ + chain, + transport: custom(window.ethereum), + }); + + const [address] = await walletClient.requestAddresses(); + + // Switch chain if needed + const currentChainId = await walletClient.getChainId(); + if (currentChainId !== chain.id) { + try { + await walletClient.switchChain({ id: chain.id }); + } catch { + throw new Error( + `Please switch to ${chainName} in your wallet.` + ); + } + } + + const publicClient = createPublicClient({ + chain, + transport: custom(window.ethereum), + }); + + return { walletClient, publicClient, address }; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..6266a0a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..659fcc2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "src", + envDir: "..", + base: "/", + build: { + outDir: "../dist", + emptyOutDir: true, + }, +});