diff --git a/nwc-integration.md b/nwc-integration.md new file mode 100644 index 0000000..c01dbde --- /dev/null +++ b/nwc-integration.md @@ -0,0 +1,737 @@ +# NWC Integration for routstrd + +## Table of Contents + +1. [What is NWC](#what-is-nwc) +2. [Current State: routstr-chat's NWC Setup](#current-state-routstr-chats-nwc-setup) +3. [Current State: routstrd's Wallet Architecture](#current-state-routstrds-wallet-architecture) +4. [Why NWC in routstrd](#why-nwc-in-routstrd) +5. [Architecture Proposal](#architecture-proposal) +6. [Configuration](#configuration) +7. [NWC Wallet Adapter](#nwc-wallet-adapter) +8. [Auto-Refill / Auto-Topup](#auto-refill--auto-topup) +9. [Tradeoffs: NWC vs cocod (Cashu-native)](#tradeoffs-nwc-vs-cocod-cashu-native) +10. [Implementation Plan](#implementation-plan) + +--- + +## What is NWC + +Nostr Wallet Connect (NWC) is an open protocol that allows apps to connect to Lightning wallets via Nostr relays. It's defined by [NIP-47](https://nwc.dev) and uses a **connection string** format: + +``` +nostr+walletconnect://?relay=&secret=<32_byte_hex> +``` + +The protocol works as follows: + +1. A **wallet service** (e.g., Alby Hub) generates a connection secret with a Nostr keypair and relay URL +2. The user copies/pastes this into the **client app** +3. The app encrypts NIP-47 request events with the shared secret and publishes them to the relay +4. The wallet service decrypts, authorizes, executes (e.g., pays an invoice), and responds with an encrypted event + +**Capabilities** (requested at connection time): + +| Method | What it does | +|-------------------|---------------------------------------| +| `pay_invoice` | Pay a BOLT-11 Lightning invoice | +| `get_balance` | Read wallet balance | +| `make_invoice` | Create a Lightning invoice | +| `lookup_invoice` | Look up invoice by payment hash | +| `get_info` | Wallet metadata (alias, network, etc) | +| `list_transactions` | List transaction history | +| `sign_message` | Sign a message with node key | + +Key libraries: `@getalby/bitcoin-connect-react` (React UI), `nostr-core` (low-level TypeScript), and `@nostr-dev-kit/ndk` (Nostr Development Kit). + +--- + +## Current State: routstr-chat's NWC Setup + +routstr-chat (the Next.js frontend) already has a complete NWC integration via **`@getalby/bitcoin-connect-react` v3.10.0**. + +### Connection Layer + +``` +┌─────────────────────────────────────────────────┐ +│ @getalby/bitcoin-connect-react │ +│ │ +│ init() ─────────────────────── BitcoinConnectClient.tsx │ +│ appName: "Routstr Chat" │ +│ filters: ["nwc"] ← NWC only, no WebLN │ +│ persistConnection: true ← survive page reload │ +│ showBalance: true │ +│ providerConfig.nwc.authorizationUrlOptions │ +│ .requestMethods: [ │ +│ "pay_invoice", │ +│ "get_balance", │ +│ "make_invoice", │ +│ "lookup_invoice" │ +│ ] │ +└──────┬──────────────────────────────────────────┘ + │ import("@getalby/bitcoin-connect-react") + │ + ┌────▼──────────────────────────────────┐ + │ useBitcoinConnectStatus() hook │ hooks/useBitcoinConnect.tsx + │ │ + │ status: connected|connecting|disconn │ + │ balance: number|null (sats) │ + │ providerName: string|null │ + │ connect() / disconnect() / reset() │ + └────┬──────────────────────────────────┘ + │ + ┌────▼──────────────────────────┐ + │ lib/nwcPayment.ts │ + │ │ + │ payWithNWC(amount, mintUrl) │ + │ 1. Create invoice on Cashu │ + │ mint │ + │ 2. Pay invoice via NWC │ + │ sendPayment(invoice) │ + │ 3. Mint tokens from paid │ + │ invoice │ + │ 4. Poll up to 30s if needed │ + └────┬──────────────────────────┘ + │ + ┌────▼──────────────────────────┐ + │ hooks/useAutoRefill.ts │ + │ │ + │ Monitors Cashu balance │ + │ Triggers payWithNWC when: │ + │ • balance < threshold (500) │ + │ • cooldown passed (5 min) │ + │ • wallet loaded │ + └────────────────────────────────┘ +``` + +### UI Components + +| Component | Purpose | +|-------------------------------|--------------------------------------------------| +| `NWCWalletManager.tsx` | Settings page: connect/disconnect, wallet name, balance | +| `BitcoinConnectStatusRow.tsx` | Compact inline status (used in deposit modals) | +| `TopUpPromptModal.tsx` | Prompts user to connect NWC when balance is low | + +### Key Design Decisions + +- **NWC-only filter** — no browser extension (WebLN) support; NWC is the sole connection path +- **Persistent connections** — `persistConnection: true` means the library stores the connection secret and reconnects on page load +- **4 NIP-47 methods** requested: `pay_invoice`, `get_balance`, `make_invoice`, `lookup_invoice` +- **Balance in sats** — the hook normalizes both `balance` (sats) and `balanceMsats` (millisats) responses +- **Auto-refill polling** at 5-second intervals with a 5-minute cooldown + +--- + +## Current State: routstrd's Wallet Architecture + +routstrd is a **Bun-based daemon/CLI** that routes LLM requests, manages providers, and handles payment via Cashu. + +### Wallet Layer + +``` +┌───────────────────────────────────────┐ +│ routstrd CLI / HTTP │ +│ │ +│ routstrd wallet status │ +│ routstrd wallet balance │ +│ routstrd wallet receive cashu │ +│ routstrd wallet send cashu │ +│ routstrd wallet receive bolt11 │ +│ routstrd wallet send bolt11 │ +│ │ +│ Daemon HTTP API: │ +│ GET /wallet/balances │ +│ POST /wallet/send │ +│ POST /wallet/receive │ +└────────┬──────────────────────────────┘ + │ +┌────────▼──────────────────────────────┐ +│ daemon/wallet/index.ts │ +│ createWalletAdapter() │ +│ │ +│ getBalances() → Record│ +│ getMintUnits() │ +│ getActiveMintUrl() │ +│ sendToken(mintUrl, amount) → token │ +│ receiveToken(token) → { success, ... }│ +└────────┬──────────────────────────────┘ + │ +┌────────▼──────────────────────────────┐ +│ daemon/wallet/cocod-client.ts │ +│ CocodClient (Unix socket HTTP) │ +│ │ +│ ping(), getStatus(), unlock() │ +│ getBalances(), listMints() │ +│ receiveCashu(token), sendCashu(amt) │ +│ receiveBolt11(amt), sendBolt11(inv) │ +│ addMint(url), getMintInfo(url) │ +└────────┬──────────────────────────────┘ + │ +┌────────▼──────────────────────────────┐ +│ cocod daemon │ +│ (Cashu wallet implementation) │ +│ │ +│ Manages: │ +│ • Nostr identities (nsec/npub) │ +│ • Cashu mints (multi-mint) │ +│ • Proofs & token minting/redeeming │ +│ • NIP-60 / NIP-61 compliance │ +│ • Lightning invoices (BOLT-11) │ +└────────────────────────────────────────┘ +``` + +### Limitations of the current setup + +1. **Single wallet backend** — routstrd is hard-coupled to `cocod`; there's no abstraction for alternative wallet backends +2. **No Lightning-native funding** — users must bring Cashu tokens or generate BOLT-11 invoices and pay them externally; there's no "one-click fund" from a Lightning wallet +3. **No balance monitoring** — routstrd doesn't auto-refill or trigger top-ups based on balance thresholds +4. **Wallet is always local** — the daemon must run cocod locally; no remote wallet support + +--- + +## Why NWC in routstrd + +### 1. Onboarding Friction + +Right now, a routstrd user must: +1. Acquire sats (somehow) +2. Use a separate tool to fund their cocod wallet via Cashu token or Lightning invoice +3. Run routstrd with sufficient balance + +With NWC, they could connect their existing Lightning wallet (Alby Hub, Zeus, Mutiny, etc.) and fund routstrd's wallet **in one click** from the same interface. + +### 2. Auto-Topup + +routstr-chat already has auto-refill logic. That logic belongs in routstrd — the daemon that actually manages the wallet. Moving it server-side means: +- Balance monitoring happens even when no frontend is open +- Top-ups trigger even for CLI/headless usage +- Multiple frontend clients (chat, mobile, other apps) all benefit from the same daemon logic + +### 3. Independent of cocod + +NWC could serve as a **funding source** for any wallet backend: +- `cocod` (current): NWC pays a BOLT-11 invoice to fund the Cashu wallet +- Future Cashu implementations: same pattern +- Direct NWC wallet: skip Cashu entirely and pay providers directly from the Lightning wallet (with appropriate budget controls) + +### 4. Consistent UX with routstr-chat + +routstr-chat already uses NWC. If routstrd also supports NWC, the experience is unified: users connect once, and both the daemon and the chat UI can use the same wallet connection. + +--- + +## Architecture Proposal + +### High-Level Design + +``` + ┌───────────────────────┐ + │ User's Lightning │ + │ Wallet (Alby Hub, │ + │ Zeus, Mutiny, etc.) │ + └───────────┬───────────┘ + │ NIP-47 over Nostr relays + │ (encrypted requests/responses) + ┌───────────▼───────────┐ + │ @getalby/bitcoin- │ (browser only — stays in chat) + │ connect-react │ + └───────────────────────┘ + + ┌───────────────────────┐ + │ nostr-core / raw │ NEW: Node.js/Bun compatible + │ NWC client │ routstrd's NWC adapter + └───────────┬───────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ┌────────▼───────┐ ┌──────▼──────┐ ┌───────▼────────┐ + │ NWC funding │ │ cocod │ │ Future wallet │ + │ adapter │ │ adapter │ │ adapters │ + │ (direct LN) │ │ (Cashu) │ │ │ + └────────┬───────┘ └──────┬──────┘ └───────┬────────┘ + │ │ │ + ┌────────▼──────────────────▼──────────────────▼────────┐ + │ Wallet Adapter Interface │ + │ │ + │ getBalances() → Record │ + │ sendPayment(invoice, amount) → { preimage, fees } │ + │ receivePayment(amount) → invoice │ + │ getStatus() → connected|disconnected|locked │ + │ connect(connectionString) → void │ + │ disconnect() → void │ + └────────────────────────────────────────────────────────┘ + │ + ┌────────▼────────────────────────────────────────┐ + │ routstrd Routing Engine │ + │ • Selects cheapest provider per model │ + │ • Deducts from wallet balance │ + │ • Triggers auto-refill when below threshold │ + └─────────────────────────────────────────────────┘ +``` + +### Two NWC Modes + +#### Mode A: NWC as Funding Source for Cashu (recommended) + +routstrd keeps using cocod for Cashu operations. NWC is a *funding source* — it pays BOLT-11 invoices generated by the Cashu mint to fund the wallet. + +``` + [Balance low] + │ + ▼ + routstrd creates BOLT-11 invoice via Cashu mint ("mint new tokens") + │ + ▼ + routstrd pays that invoice via NWC + │ + ▼ + Cashu mint issues tokens → balance increases +``` + +This is **exactly** what `routstr-chat/lib/nwcPayment.ts` already does: `createLightningInvoice` → `sendPayment` → `mintTokensFromPaidInvoice`. + +#### Mode B: NWC as Standalone Wallet (future) + +Skip Cashu entirely. routstrd pays providers directly from the Lightning wallet via NWC. This requires: +- A provider that accepts Lightning payments (or an L402-like scheme) +- Budget/rate limiting on the NWC connection (built into NIP-47) +- No token management overhead + +This is simpler for users who don't need Cashu's privacy properties. + +--- + +## Configuration + +### New routstrd config fields + +```json +// ~/.routstrd/config.json +{ + "port": 8008, + "provider": null, + "mode": "apikeys", + + // NEW — NWC configuration + "nwc": { + // Mode A: NWC funds a Cashu wallet + "mode": "funding_source", + + // NWC connection string (nostr+walletconnect://...) + "connectionString": "nostr+walletconnect://b889ff5b...?relay=wss://relay.getalby.com/v1&secret=71a8c14c...", + + // Auto-refill settings (in sats) + "autoRefill": { + "enabled": true, + "threshold": 500, // refill when Cashu balance < 500 sats + "amount": 1000, // refill 1000 sats at a time + "cooldownMs": 300000 // 5 minutes between refills + } + } +} +``` + +### CLI commands + +```sh +# Set up NWC connection +routstrd nwc connect +routstrd nwc connect # interactive: paste or scan QR + +# Status +routstrd nwc status # connected/disconnected, alias, balance + +# Manage +routstrd nwc disconnect +routstrd nwc auto-refill on --threshold 500 --amount 1000 +routstrd nwc auto-refill off + +# Manual operations +routstrd nwc fund # manually fund wallet from NWC +routstrd nwc pay-invoice # pay a specific invoice via NWC +``` + +--- + +## NWC Wallet Adapter + +### Dependency: `nostr-core` + +For Bun/Node.js compatibility, use `nostr-core` instead of `@getalby/bitcoin-connect-react`: + +```sh +bun add nostr-core +``` + +### Adapter Implementation Sketch + +```ts +// src/daemon/wallet/nwc-adapter.ts + +import { NWC } from "nostr-core"; + +export interface NwcConfig { + connectionString: string; + autoRefill?: { + enabled: boolean; + threshold: number; // sats + amount: number; // sats + cooldownMs: number; // milliseconds + }; +} + +export interface NwcWalletAdapter { + connect(): Promise<{ + alias: string; + pubkey: string; + methods: string[]; + }>; + disconnect(): Promise; + isConnected(): boolean; + getBalance(): Promise; // sats + getInfo(): Promise<{ + alias: string; + pubkey: string; + network: string; + methods: string[]; + }>; + payInvoice(invoice: string, amount?: number): Promise<{ + preimage: string; + fees_paid?: number; + }>; + makeInvoice(params: { + amount: number; // msats + description?: string; + }): Promise<{ invoice: string }>; + getBudget(): Promise<{ + max_amount: number; + budget_renewal: string; + remaining: number; + } | null>; +} + +export function createNwcAdapter(config: NwcConfig): NwcWalletAdapter { + let nwc: NWC | null = null; + + return { + async connect() { + nwc = new NWC(config.connectionString); + nwc.replyTimeout = 60000; // 60s wallet reply timeout + nwc.publishTimeout = 10000; // 10s relay publish timeout + + await nwc.connect(); + const info = await nwc.getInfo(); + + return { + alias: info.alias || "Unknown Wallet", + pubkey: info.pubkey || "", + methods: info.methods || [], + }; + }, + + async disconnect() { + if (nwc) { + nwc.close(); + nwc = null; + } + }, + + isConnected() { + return nwc !== null; + }, + + async getBalance() { + if (!nwc) throw new Error("NWC not connected"); + const { balance } = await nwc.getBalance(); + // balance is in msats from NIP-47; convert to sats + return Math.floor(balance / 1000); + }, + + async getInfo() { + if (!nwc) throw new Error("NWC not connected"); + return nwc.getInfo(); + }, + + async payInvoice(invoice, amount) { + if (!nwc) throw new Error("NWC not connected"); + return nwc.payInvoice(invoice, amount); + }, + + async makeInvoice(params) { + if (!nwc) throw new Error("NWC not connected"); + return nwc.makeInvoice(params); + }, + + async getBudget() { + if (!nwc) throw new Error("NWC not connected"); + try { + return await nwc.getBudget(); + } catch { + return null; // budget info not always available + } + }, + }; +} +``` + +### Integration with existing Wallet Adapter + +The existing `createWalletAdapter()` can be extended to accept an NWC funding source: + +```ts +// src/daemon/wallet/index.ts (modified) + +export async function createWalletAdapter(options: { + cocodPath?: string | null; + walletClient?: CocodClient; + nwcFundingSource?: NwcWalletAdapter; // NEW + nwcAutoRefill?: AutoRefillConfig; // NEW +}) { + const client = /* ... existing cocod setup ... */; + const nwc = options.nwcFundingSource; + + const walletAdapter = { + // ... existing methods ... + + // NEW methods + async fundFromNWC(amount: number): Promise<{ + success: boolean; + invoice: string; + preimage?: string; + error?: string; + }> { + if (!nwc || !nwc.isConnected()) { + throw new Error("NWC not connected"); + } + + // Create bolt11 invoice via cocod to fund the Cashu wallet + const invoice = await client.receiveBolt11(amount, activeMintUrl!); + + // Pay it via NWC + const { preimage } = await nwc.payInvoice(invoice, amount); + + return { success: true, invoice, preimage }; + }, + + getNwcStatus() { + return { + connected: nwc?.isConnected() ?? false, + balance: null, // fetched async + }; + } + }; + + // Auto-refill loop (if configured) + if (options.nwcAutoRefill?.enabled) { + startAutoRefillLoop(walletAdapter, options.nwcAutoRefill); + } + + return walletAdapter; +} +``` + +--- + +## Auto-Refill / Auto-Topup + +### Moving from routstr-chat to routstrd + +Currently, `useAutoRefill.ts` lives in routstr-chat (the browser). The same logic should move to routstrd so it runs regardless of whether a frontend is open. + +```ts +// src/daemon/wallet/auto-refill.ts + +import type { CocodClient } from "./cocod-client"; +import type { NwcWalletAdapter } from "./nwc-adapter"; +import { logger } from "../../utils/logger"; + +export interface AutoRefillConfig { + threshold: number; // sats + amount: number; // sats + cooldownMs: number; // milliseconds +} + +export function startAutoRefillLoop( + cocod: CocodClient, + nwc: NwcWalletAdapter, + config: AutoRefillConfig, + intervalMs: number = 5000, +): () => void { + let lastRefillAt = 0; + let running = true; + let timeout: ReturnType | null = null; + + async function checkAndRefill() { + if (!running) return; + if (!nwc.isConnected()) return; + + const now = Date.now(); + if (now - lastRefillAt < config.cooldownMs) return; + + try { + const balances = await cocod.getBalances(); + const totalBalance = Object.values(balances).reduce( + (sum, b) => sum + (typeof b === "number" ? b : (b as any).sats ?? 0), + 0, + ); + + if (totalBalance < config.threshold) { + logger.log( + `[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`, + ); + + // Create bolt11 invoice from cocod + const activeMints = await cocod.listMints(); + const mintUrl = activeMints[0]; + if (!mintUrl) { + logger.error("[auto-refill] No active mint configured"); + return; + } + + const invoice = await cocod.receiveBolt11(config.amount, mintUrl); + const { preimage } = await nwc.payInvoice(invoice, config.amount); + + logger.log( + `[auto-refill] Successfully refilled ${config.amount} sats. Preimage: ${preimage}`, + ); + lastRefillAt = now; + } + } catch (error) { + logger.error("[auto-refill] Error:", error); + } + } + + // Check immediately, then on interval + checkAndRefill(); + timeout = setInterval(checkAndRefill, intervalMs); + + return () => { + running = false; + if (timeout) clearInterval(timeout); + }; +} +``` + +--- + +## Tradeoffs: NWC vs cocod (Cashu-native) + +| Dimension | cocod (Cashu-native) | NWC (Lightning-native) | +|-----------|---------------------|------------------------| +| **Privacy** | Strong: Chaumian ecash, unlinkable tokens | Weak: all payments visible to wallet provider | +| **Setup** | Complex: requires running cocod, managing mints | Simple: paste a connection string | +| **Funding** | Manual: import Cashu tokens or pay invoices externally | Automatic: funds cascade from connected Lightning wallet | +| **Reliability** | Depends on Cashu mint uptime | Depends on Nostr relay uptime | +| **Fees** | Cashu mint fees (typically low) | Lightning routing fees (variable) | +| **Ecosystem** | Growing but niche (Cashu ecosystem) | Mature (Lightning Network, 100+ wallets) | +| **Browser support** | Requires wallet extension or token input | Works with any NWC-compatible wallet | +| **Budget controls** | None built in | NIP-47 supports per-connection budgets, spend limits, renewal periods | +| **Dependencies** | Must run cocod process | Need a Nostr relay (can be public) | +| **Multi-mint** | Supported natively by cocod | N/A (Lightning is single-network) | + +### Recommended Strategy + +**Use both, with NWC as the funding bridge.** + +``` +Lightning Wallet (NWC) ──funds──▶ cocod Cashu Wallet ──pays──▶ LLM Providers + ▲ + │ (privacy, multi-mint, + │ token portability) + │ + Users hold sats in + Cashu tokens for + day-to-day use +``` + +The NWC connection only activates when the Cashu balance runs low — it's a "refill line," not the primary payment rail. This preserves Cashu's privacy properties for routine LLM payments while making the funding experience seamless. + +--- + +## Implementation Plan + +### Phase 1: NWC as Funding Source for cocod (1-2 days) + +1. **Add `nostr-core` dependency** to routstrd +2. **Create `src/daemon/wallet/nwc-adapter.ts`** — NWC client wrapper +3. **Extend `createWalletAdapter()`** to accept an optional NWC adapter +4. **Add auto-refill loop** in the daemon startup +5. **Add config fields** for NWC connection string and auto-refill settings +6. **Add CLI commands**: `routstrd nwc connect`, `routstrd nwc status`, etc. + +### Phase 2: Shared NWC Connection (1 day) + +7. **Expose NWC status** via daemon HTTP API so routstr-chat can read it +8. **Sync** — if NWC is connected in routstrd, routstr-chat doesn't need its own connection +9. **Unify settings** — auto-refill configured once, in routstrd config + +### Phase 3: Standalone NWC Mode (future, 2-3 days) + +10. **Skip Cashu** — NWC adapter becomes the primary wallet backend +11. **Provider integration** — providers that accept Lightning payments directly +12. **Budget controls** — leverage NIP-47 budget features for per-model spend limits + +--- + +## Appendix: NWC Connection Flow + +``` +┌──────────────┐ ┌───────────────┐ ┌──────────────┐ +│ routstrd │ │ Nostr Relay │ │ Alby Hub / │ +│ (client) │ │ │ │ Wallet Svc │ +└──────┬───────┘ └───────┬───────┘ └──────┬───────┘ + │ │ │ + │ 1. User provides │ │ + │ connection string │ │ + │ nostr+walletconnect://?relay=... │ + │ │ │ + │ 2. Connect to relay (WebSocket) │ + │────────────────────▶│ │ + │ │ │ + │ 3. Subscribe to NIP-47 responses │ + │ (kind 23195, p=) │ + │────────────────────▶│ │ + │ │ │ + │ 4. NIP-47 request event │ + │ (kind 23194, encrypted, p=) │ + │────────────────────▶│ │ + │ │ 5. Relay forwards │ + │ │────────────────────▶│ + │ │ │ 6. Wallet decrypts, + │ │ │ authorizes, pays + │ │ │ + │ │ 7. Response event │ + │ │◀────────────────────│ + │ 8. Receive response│ │ + │◀────────────────────│ │ + │ │ │ + │ 9. Parse & return │ │ + │ (preimage, balance,│ │ + │ invoice, etc.) │ │ + │ │ │ +``` + +## Appendix: Key Files Reference + +### routstr-chat (existing NWC integration) + +| File | Purpose | +|------|---------| +| `components/bitcoin-connect/BitcoinConnectClient.tsx` | NWC init with `@getalby/bitcoin-connect-react` | +| `hooks/useBitcoinConnect.tsx` | Connection state, balance, connect/disconnect | +| `lib/nwcPayment.ts` | `payWithNWC()` — invoice creation → NWC payment → Cashu token minting | +| `hooks/useAutoRefill.ts` | Balance monitoring + auto-refill trigger | +| `components/settings/NWCWalletManager.tsx` | Settings UI for Connect/Disconnect | +| `components/bitcoin-connect/BitcoinConnectStatusRow.tsx` | Compact inline status widget | + +### routstrd (new NWC integration target) + +| File | Purpose | +|------|---------| +| `src/daemon/wallet/index.ts` | `createWalletAdapter()` — extend to accept NWC | +| `src/daemon/wallet/cocod-client.ts` | `CocodClient` — stays as-is for Cashu ops | +| `src/daemon/wallet/nwc-adapter.ts` | **NEW** — NWC client wrapper using `nostr-core` | +| `src/daemon/wallet/auto-refill.ts` | **NEW** — server-side auto-refill loop | +| `src/daemon/config-store.ts` | Extend config types to include NWC fields | +| `src/utils/config.ts` | `RoutstrdConfig` type — add `nwc` field | +| `src/cli.ts` (or new nwc command module) | **NEW** — `routstrd nwc *` CLI commands | diff --git a/src/cli.ts b/src/cli.ts index f1c7472..800de0e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1253,6 +1253,123 @@ walletMintsCmd }); }); +// ── NWC (Nostr Wallet Connect) commands ───────────────────────── + +const nwcCmd = program + .command("nwc") + .description("Manage NWC (Nostr Wallet Connect) integration"); + +nwcCmd + .command("connect") + .description("Connect to a Lightning wallet via NWC") + .argument("[connection-string]", "NWC connection string (nostr+walletconnect://...)") + .action(async (connectionString?: string) => { + if (!connectionString) { + // Interactive mode: prompt for connection string + const rl = require("readline").createInterface({ + input: process.stdin, + output: process.stdout, + }); + connectionString = await new Promise((resolve) => { + rl.question("Paste your NWC connection string: ", (answer: string) => { + rl.close(); + resolve(answer.trim()); + }); + }); + } + + // Validate the connection string format + const { validateConnectionString } = await import( + "./daemon/wallet/nwc-client" + ); + const validation = validateConnectionString(connectionString); + if (!validation.valid) { + console.error(`Invalid NWC connection string: ${validation.error}`); + process.exit(1); + } + + await handleDaemonCommand("/nwc/connect", { + method: "POST", + body: { connectionString }, + }); + + console.log("\nRun 'routstrd restart' to connect to the NWC wallet."); + }); + +nwcCmd + .command("disconnect") + .description("Disconnect from NWC wallet") + .action(async () => { + await handleDaemonCommand("/nwc/disconnect", { + method: "POST", + }); + console.log("\nRun 'routstrd restart' to apply."); + }); + +nwcCmd + .command("status") + .description("Show NWC connection status and wallet info") + .action(async () => { + await handleDaemonCommand("/nwc/status"); + }); + +nwcCmd + .command("fund ") + .description("Manually fund the Cashu wallet from the connected NWC wallet") + .action(async (amount: string) => { + const parsedAmount = parsePositiveIntOrExit(amount, "amount"); + await handleDaemonCommand("/nwc/fund", { + method: "POST", + body: { amount: parsedAmount }, + }); + }); + +const autoRefillCmd = nwcCmd + .command("auto-refill") + .description("Manage automatic wallet refill from NWC"); + +autoRefillCmd + .command("on") + .description("Enable auto-refill") + .option( + "--threshold ", + "Refill when Cashu balance drops below this many sats", + "500", + ) + .option("--amount ", "Refill this many sats at a time", "1000") + .option( + "--cooldown ", + "Minimum time between refills in seconds", + "300", + ) + .action(async (options: { threshold: string; amount: string; cooldown: string }) => { + const threshold = parsePositiveIntOrExit(options.threshold, "threshold"); + const amount = parsePositiveIntOrExit(options.amount, "amount"); + const cooldownSec = parsePositiveIntOrExit(options.cooldown, "cooldown"); + + await handleDaemonCommand("/nwc/auto-refill", { + method: "POST", + body: { + enabled: true, + threshold, + amount, + cooldownMs: cooldownSec * 1000, + }, + }); + console.log("\nRun 'routstrd restart' to apply."); + }); + +autoRefillCmd + .command("off") + .description("Disable auto-refill") + .action(async () => { + await handleDaemonCommand("/nwc/auto-refill", { + method: "POST", + body: { enabled: false }, + }); + console.log("\nRun 'routstrd restart' to apply."); + }); + // Stop program .command("stop") diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 68df337..26edc3a 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -8,6 +8,7 @@ import { } from "@routstr/sdk"; import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk"; import { logger } from "../../utils/logger"; +import { loadDaemonConfig, saveDaemonConfig } from "../config-store"; import { CocodHttpError, type CocodClient, @@ -430,6 +431,138 @@ export function createDaemonRequestHandler(deps: { return; } + // ── NWC endpoints ───────────────────────────────────────────── + + if (req.method === "GET" && url.pathname === "/nwc/status") { + await respond(res, async () => { + const status = await deps.walletAdapter.getNwcStatus(); + const autoRefill = deps.walletAdapter.getAutoRefillConfig(); + return { + output: { + ...status, + autoRefill: autoRefill + ? { + enabled: autoRefill.enabled, + threshold: autoRefill.threshold, + amount: autoRefill.amount, + cooldownMs: autoRefill.cooldownMs, + } + : undefined, + }, + }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/nwc/connect") { + await respond(res, async () => { + const body = await readJsonBody(req); + const connectionString = getRequiredStringField(body, "connectionString"); + + // Reload config and set NWC connection + const config = await loadDaemonConfig(); + config.nwc = { + mode: "funding_source", + connectionString, + autoRefill: config.nwc?.autoRefill, + }; + saveDaemonConfig(config); + + return { + output: { + message: + "NWC connection string saved. Restart the daemon to connect.", + }, + }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/nwc/disconnect") { + await respond(res, async () => { + const config = await loadDaemonConfig(); + if (config.nwc) { + delete config.nwc.connectionString; + if (config.nwc.autoRefill) { + config.nwc.autoRefill.enabled = false; + } + } + saveDaemonConfig(config); + return { + output: { message: "NWC disconnected. Restart the daemon to apply." }, + }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/nwc/fund") { + await respond(res, async () => { + const body = await readJsonBody(req); + const amount = getRequiredPositiveNumberField(body, "amount"); + const result = await deps.walletAdapter.fundFromNWC(amount); + if (!result.success) { + throw new Error(result.error || "NWC funding failed"); + } + return { + output: { + message: `Successfully funded ${amount} sats via NWC`, + invoice: result.invoice, + preimage: result.preimage, + amount, + }, + }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/nwc/auto-refill") { + await respond(res, async () => { + const body = await readJsonBody(req); + const enabled = body.enabled === true || body.enabled === "true"; + + const config = await loadDaemonConfig(); + if (!config.nwc) { + config.nwc = { mode: "funding_source" }; + } + + if (enabled) { + const threshold = + typeof body.threshold === "number" && body.threshold > 0 + ? body.threshold + : 500; + const amount = + typeof body.amount === "number" && body.amount > 0 + ? body.amount + : 1000; + const cooldownMs = + typeof body.cooldownMs === "number" && body.cooldownMs > 0 + ? body.cooldownMs + : 300000; + + config.nwc.autoRefill = { + enabled: true, + threshold, + amount, + cooldownMs, + }; + } else { + config.nwc.autoRefill = config.nwc.autoRefill + ? { ...config.nwc.autoRefill, enabled: false } + : { enabled: false, threshold: 500, amount: 1000, cooldownMs: 300000 }; + } + + saveDaemonConfig(config); + + return { + output: { + message: `Auto-refill ${enabled ? "enabled" : "disabled"}. Restart daemon to apply.`, + autoRefill: config.nwc.autoRefill, + }, + }; + }); + return; + } + if (req.method === "GET" && url.pathname === "/models") { try { const forceRefresh = diff --git a/src/daemon/index.ts b/src/daemon/index.ts index b32eef9..88e7899 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -11,6 +11,8 @@ import { import type { SdkLogger } from "@routstr/sdk"; import { DB_PATH, SOCKET_PATH, PID_FILE } from "../utils/config"; import { logger } from "../utils/logger"; +import { createNwcClient, parseConnectionString } from "./wallet/nwc-client"; +import type { NwcClient } from "./wallet/nwc-client"; function makeSdkLogger(prefix?: string): SdkLogger { const tag = prefix ? `[${prefix}]` : undefined; @@ -69,11 +71,69 @@ async function main(): Promise { createModelService(modelManager, store); const walletClient = createCocodClient({ cocodPath: config.cocodPath }); + + // ── NWC (Nostr Wallet Connect) setup ────────────────────────── + + let nwcClient: NwcClient | undefined; + let nwcCleanup: (() => void) | undefined; + + if (config.nwc?.connectionString) { + try { + parseConnectionString(config.nwc.connectionString); + + nwcClient = createNwcClient({ + connectionString: config.nwc.connectionString, + }); + + logger.log("[nwc] NWC connection configured, connecting..."); + await nwcClient.connect(); + + const info = await nwcClient.getInfo(); + logger.log( + `[nwc] Connected to NWC wallet: ${info.alias} (${info.pubkey.slice(0, 8)}...)`, + ); + logger.log(`[nwc] Supported methods: ${info.methods.join(", ")}`); + + try { + const balance = await nwcClient.getBalance(); + logger.log(`[nwc] Wallet balance: ${balance} sats`); + } catch { + // balance might not be available + } + } catch (error) { + logger.error( + "[nwc] Failed to connect NWC:", + (error as Error).message, + ); + // Don't crash — continue without NWC + nwcClient = undefined; + } + } + + const nwcAutoRefill = + config.nwc?.autoRefill?.enabled && nwcClient + ? { + threshold: config.nwc.autoRefill.threshold, + amount: config.nwc.autoRefill.amount, + cooldownMs: config.nwc.autoRefill.cooldownMs, + } + : undefined; + const walletAdapter = await createWalletAdapter({ cocodPath: config.cocodPath, walletClient, + nwcClient, + autoRefill: nwcAutoRefill, }); + // Handle NWC cleanup on shutdown + if (nwcClient) { + nwcCleanup = () => { + logger.log("[nwc] Shutting down NWC connection..."); + nwcClient!.disconnect(); + }; + } + const refundClient = new RoutstrClient( walletAdapter, storageAdapter, @@ -208,6 +268,7 @@ async function main(): Promise { server.on("close", () => { stopModelRefreshJob(); stopRefundJob(); + if (nwcCleanup) nwcCleanup(); }); server.listen(port, async () => { diff --git a/src/daemon/wallet/auto-refill.ts b/src/daemon/wallet/auto-refill.ts new file mode 100644 index 0000000..2b7adf3 --- /dev/null +++ b/src/daemon/wallet/auto-refill.ts @@ -0,0 +1,107 @@ +// Auto-refill loop for routstrd +// Monitors Cocod Cashu balance and triggers NWC funding when below threshold. +// This runs server-side, so refills happen regardless of whether a frontend is open. + +import type { CocodClient } from "./cocod-client"; +import type { NwcClient } from "./nwc-client"; +import { logger } from "../../utils/logger"; + +export interface AutoRefillConfig { + /** Minimum sats balance before triggering a refill */ + threshold: number; + /** Amount of sats to refill each time */ + amount: number; + /** Minimum time between refills (milliseconds) */ + cooldownMs: number; +} + +export function startAutoRefillLoop( + cocod: CocodClient, + nwc: NwcClient, + config: AutoRefillConfig, + intervalMs: number = 5000, +): () => void { + let lastRefillAt = 0; + let running = true; + let timeout: ReturnType | null = null; + let checkInProgress = false; + + async function checkAndRefill(): Promise { + if (!running) return; + if (checkInProgress) return; + if (!nwc.isConnected()) { + // NWC not connected — nothing to do + return; + } + + const now = Date.now(); + if (now - lastRefillAt < config.cooldownMs) { + return; + } + + checkInProgress = true; + + try { + const balances = await cocod.getBalances(); + const totalBalance = Object.values(balances).reduce( + (sum, b) => sum + (typeof b === "number" ? b : 0), + 0, + ); + + if (totalBalance >= config.threshold) { + // Balance is sufficient + return; + } + + logger.log( + `[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`, + ); + + // Get active mint + const mints = await cocod.listMints(); + const mintUrl = mints[0]; + if (!mintUrl) { + logger.error("[auto-refill] No active mint configured"); + return; + } + + // Step 1: Create a BOLT-11 invoice via cocod to fund the Cashu wallet + logger.log( + `[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mintUrl}...`, + ); + const invoice = await cocod.receiveBolt11(config.amount, mintUrl); + + // Step 2: Pay the invoice via NWC + logger.log(`[auto-refill] Paying invoice via NWC...`); + const { preimage } = await nwc.payInvoice(invoice, config.amount); + + // Step 3: The Cashu mint should automatically detect the paid invoice + // and issue tokens. We don't need to explicitly mint here; cocod + // handles this on its end when the mint sees the payment. + logger.log( + `[auto-refill] Successfully refilled ${config.amount} sats. Preimage: ${preimage.slice(0, 16)}...`, + ); + lastRefillAt = now; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[auto-refill] Error: ${message}`); + } finally { + checkInProgress = false; + } + } + + // Check immediately on start + checkAndRefill(); + + // Then poll on interval + timeout = setInterval(checkAndRefill, intervalMs); + + return () => { + running = false; + if (timeout) { + clearInterval(timeout); + timeout = null; + } + logger.log("[auto-refill] Stopped"); + }; +} diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index a1480f6..6583a85 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -2,6 +2,8 @@ import { getDecodedToken, Amount } from "@cashu/cashu-ts"; import { InsufficientBalanceError } from "@routstr/sdk"; import { logger } from "../../utils/logger"; import { createCocodClient, type CocodClient } from "./cocod-client"; +import type { NwcClient } from "./nwc-client"; +import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill"; export function decodeCashuTokenAmount(token: string): { amount: number; @@ -14,11 +16,17 @@ export function decodeCashuTokenAmount(token: string): { return { amount, unit }; } +export interface WalletAdapterOptions { + cocodPath?: string | null; + walletClient?: CocodClient; + /** Optional NWC client for Lightning funding */ + nwcClient?: NwcClient; + /** Auto-refill configuration (requires nwcClient) */ + autoRefill?: AutoRefillConfig; +} + export async function createWalletAdapter( - options: { - cocodPath?: string | null; - walletClient?: CocodClient; - } = {}, + options: WalletAdapterOptions = {}, ) { const client = options.walletClient || createCocodClient({ cocodPath: options.cocodPath }); @@ -57,6 +65,87 @@ export async function createWalletAdapter( getActiveMintUrl(): string | null { return activeMintUrl; }, + + // ── NWC funding methods ──────────────────────────────────── + + /** Fund the Cashu wallet from NWC by creating & paying a BOLT-11 invoice */ + async fundFromNWC(amount: number): Promise<{ + success: boolean; + invoice: string; + preimage?: string; + error?: string; + }> { + const nwc = options.nwcClient; + if (!nwc || !nwc.isConnected()) { + return { success: false, invoice: "", error: "NWC not connected" }; + } + + // Ensure we have an active mint + await syncMintState(); + const mintUrl = activeMintUrl; + if (!mintUrl) { + return { success: false, invoice: "", error: "No active mint configured" }; + } + + try { + // Step 1: Create a BOLT-11 invoice via cocod + const invoice = await client.receiveBolt11(amount, mintUrl); + + // Step 2: Pay it via NWC + const { preimage } = await nwc.payInvoice(invoice, amount); + + return { success: true, invoice, preimage }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, invoice: "", error: message }; + } + }, + + /** Get NWC connection status and wallet info */ + async getNwcStatus(): Promise<{ + connected: boolean; + alias?: string; + pubkey?: string; + network?: string; + methods?: string[]; + balance?: number; + error?: string; + }> { + const nwc = options.nwcClient; + if (!nwc) { + return { connected: false, error: "NWC not configured" }; + } + + if (!nwc.isConnected()) { + return { connected: false, error: "NWC not connected" }; + } + + try { + const info = await nwc.getInfo(); + let balance: number | undefined; + try { + balance = await nwc.getBalance(); + } catch { + // Balance might not be available + } + return { + connected: true, + alias: info.alias, + pubkey: info.pubkey, + network: info.network, + methods: info.methods, + balance, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { connected: false, error: message }; + } + }, + + /** Get the auto-refill config */ + getAutoRefillConfig(): AutoRefillConfig | undefined { + return options.autoRefill; + }, async sendToken(mintUrl: string, amount: number): Promise { const maxRetries = 3; const retryDelayMs = 5000; @@ -110,6 +199,33 @@ export async function createWalletAdapter( }, }; + // ── Auto-refill setup ──────────────────────────────────────── + + let stopAutoRefill: (() => void) | undefined; + + if (options.autoRefill && options.nwcClient) { + // Start after initial state sync + const startRefill = () => { + stopAutoRefill = startAutoRefillLoop( + client, + options.nwcClient!, + options.autoRefill!, + ); + logger.log( + `[wallet] Auto-refill enabled: threshold=${options.autoRefill!.threshold} sats, amount=${options.autoRefill!.amount} sats, cooldown=${options.autoRefill!.cooldownMs}ms`, + ); + }; + + // If NWC is already connected, start immediately; otherwise, + // the daemon will start refill after NWC connects + if (options.nwcClient.isConnected()) { + startRefill(); + } + + // Store the start function so the daemon can call it after NWC connects + (walletAdapter as any)._startAutoRefill = startRefill; + } + try { const [balances, mints] = await Promise.all([ client.getBalances(), diff --git a/src/daemon/wallet/nwc-client.ts b/src/daemon/wallet/nwc-client.ts new file mode 100644 index 0000000..559b343 --- /dev/null +++ b/src/daemon/wallet/nwc-client.ts @@ -0,0 +1,545 @@ +// NIP-47 NWC (Nostr Wallet Connect) client for routstrd +// Uses nostr-tools for key management and NIP-04 encryption, +// Bun's native WebSocket for relay communication. + +import { + generateSecretKey, + getPublicKey, + nip04, + finalizeEvent, + type EventTemplate, +} from "nostr-tools"; +import { logger } from "../../utils/logger"; +import type { + NwcConnectionString, + NwcRequest, + NwcResponse, + NostrEvent, + NwcMethod, +} from "./nwc-types"; +import { NWC_REQUEST_KIND, NWC_RESPONSE_KIND } from "./nwc-types"; + +// ── Connection string parsing ────────────────────────────────────── + +export function parseConnectionString(uri: string): NwcConnectionString { + const url = new URL(uri); + + if (url.protocol !== "nostr+walletconnect:") { + throw new Error( + `Invalid NWC connection string protocol: ${url.protocol}. Expected nostr+walletconnect:`, + ); + } + + const pubkey = url.hostname; + const relay = url.searchParams.get("relay"); + const secret = url.searchParams.get("secret"); + + if (!pubkey || pubkey.length !== 64) { + throw new Error("Invalid NWC connection string: missing or invalid pubkey"); + } + if (!relay) { + throw new Error("Invalid NWC connection string: missing relay parameter"); + } + if (!secret || secret.length !== 64) { + throw new Error( + "Invalid NWC connection string: missing or invalid secret (expected 32-byte hex)", + ); + } + + return { pubkey, relay, secret }; +} + +export function validateConnectionString( + uri: string, +): { valid: true; parsed: NwcConnectionString } | { valid: false; error: string } { + try { + const parsed = parseConnectionString(uri); + return { valid: true, parsed }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} + +// ── Client options and interface ──────────────────────────────────── + +export interface NwcClientOptions { + connectionString: string; + /** Optional client private key (hex). Generated randomly if omitted. */ + clientSecretKey?: string; + /** Timeout for wallet response (ms). Default: 60000 */ + replyTimeoutMs?: number; + /** Timeout for relay operations (ms). Default: 10000 */ + publishTimeoutMs?: number; + /** Max auto-reconnect attempts. Default: 5 */ + maxReconnectAttempts?: number; +} + +export interface NwcClient { + connect(): Promise; + disconnect(): void; + isConnected(): boolean; + getInfo(): Promise<{ + alias: string; + pubkey: string; + network?: string; + methods: string[]; + }>; + getBalance(): Promise; // in sats + payInvoice(invoice: string, amount?: number): Promise<{ + preimage: string; + fees_paid?: number; + }>; + makeInvoice(params: { + amount: number; + description?: string; + }): Promise<{ + invoice: string; + payment_hash: string; + amount: number; + }>; + lookupInvoice(params: { + payment_hash?: string; + invoice?: string; + }): Promise<{ + transaction_type: "incoming" | "outgoing"; + invoice?: string; + preimage?: string; + payment_hash: string; + amount: number; + fees_paid?: number; + settled_at?: number; + } | null>; +} + +// ── Client implementation ─────────────────────────────────────────── + +/** A queued request with method, params, and promise callbacks */ +interface QueuedCall { + method: NwcMethod; + params: Record; + resolve: (response: NwcResponse) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +export function createNwcClient(options: NwcClientOptions): NwcClient { + const parsed = parseConnectionString(options.connectionString); + const walletPubkey = parsed.pubkey; + const relayUrl = parsed.relay; + const replyTimeoutMs = options.replyTimeoutMs ?? 60000; + const publishTimeoutMs = options.publishTimeoutMs ?? 10000; + const maxReconnectAttempts = options.maxReconnectAttempts ?? 5; + + // Generate or use provided client keypair + const clientSecretKey = options.clientSecretKey + ? Buffer.from(options.clientSecretKey, "hex") + : generateSecretKey(); + const clientPubkey = getPublicKey(clientSecretKey); + const clientPubkeyHex = Buffer.from(clientPubkey).toString("hex"); + + // ── State ────────────────────────────────────────────────────── + let ws: WebSocket | null = null; + let connected = false; + let subscriptionId: string | null = null; + let reconnectAttempts = 0; + let stopReconnecting = false; + + // Serialized request queue — we process one NWC request at a time + // because NIP-47 doesn't mandate request/response correlation IDs. + const queue: QueuedCall[] = []; + let sending = false; + + // ── Logging helpers ──────────────────────────────────────────── + function log(...args: unknown[]) { + logger.log("[nwc]", ...args); + } + function debugLog(...args: unknown[]) { + logger.debug("[nwc]", ...args); + } + + // ── Nostr helpers ────────────────────────────────────────────── + function createSignedEvent( + kind: number, + content: string, + tags: string[][], + ): NostrEvent { + const template: EventTemplate = { + kind, + created_at: Math.floor(Date.now() / 1000), + tags, + content, + }; + const event = finalizeEvent(template, clientSecretKey); + return { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + }; + } + + function sendRaw(message: unknown[]): void { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify(message)); + } + + // ── Queue processing ─────────────────────────────────────────── + + /** Dequeue and send the next request from the queue head */ + async function sendNextFromQueue(): Promise { + if (sending || queue.length === 0 || !connected || !ws) return; + sending = true; + + const call = queue[0]!; + try { + const requestContent: NwcRequest = { + method: call.method, + params: call.params, + }; + const requestJson = JSON.stringify(requestContent); + + debugLog(`Sending ${call.method} (queue depth: ${queue.length})`); + + const encrypted = await nip04.encrypt( + clientSecretKey, + walletPubkey, + requestJson, + ); + + const event = createSignedEvent(NWC_REQUEST_KIND, encrypted, [ + ["p", walletPubkey], + ]); + + sendRaw(["EVENT", event]); + debugLog(`Published ${call.method} request ${event.id.slice(0, 8)}...`); + } catch (error) { + // Send failed — reject this call and move on + const failed = queue.shift()!; + clearTimeout(failed.timeout); + failed.reject( + error instanceof Error ? error : new Error(String(error)), + ); + } finally { + sending = false; + } + } + + /** Handle a response event from the wallet */ + async function handleResponse(event: NostrEvent): Promise { + if (event.pubkey !== walletPubkey) return; + + const pTag = event.tags.find((t) => t.length >= 2 && t[0] === "p"); + if (!pTag || pTag[1] !== clientPubkeyHex) return; + + if (queue.length === 0) { + debugLog("NWC response received but no pending calls"); + return; + } + + const call = queue.shift()!; + clearTimeout(call.timeout); + + try { + debugLog(`Decrypting response ${event.id.slice(0, 8)}...`); + const decrypted = await nip04.decrypt( + clientSecretKey, + walletPubkey, + event.content, + ); + debugLog(`Decrypted: ${decrypted.slice(0, 200)}`); + + const response = JSON.parse(decrypted) as NwcResponse; + + if (response.error) { + call.reject( + new Error( + `NWC error (${response.error.code}): ${response.error.message}`, + ), + ); + } else { + call.resolve(response); + } + } catch (error) { + call.reject( + new Error( + `Failed to parse NWC response: ${(error as Error).message}`, + ), + ); + } + + // Process next in queue + sendNextFromQueue(); + } + + // ── Enqueue a call ───────────────────────────────────────────── + + function enqueueCall( + method: NwcMethod, + params: Record, + ): Promise { + if (!connected || !ws) { + return Promise.reject(new Error("NWC client not connected")); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const idx = queue.findIndex((c) => c.resolve === resolve); + if (idx >= 0) { + queue.splice(idx, 1); + } + sendNextFromQueue(); + reject( + new Error(`NWC '${method}' timed out after ${replyTimeoutMs}ms`), + ); + }, replyTimeoutMs); + + queue.push({ method, params, resolve, reject, timeout }); + sendNextFromQueue(); + }); + } + + // ── Relay message handler ────────────────────────────────────── + + function handleRelayMessage(raw: string): void { + let msg: unknown[]; + try { + msg = JSON.parse(raw); + } catch { + return; + } + + if (!Array.isArray(msg) || msg.length < 2) return; + + const type = msg[0] as string; + + if (type === "EVENT" && msg.length >= 3) { + const event = msg[2] as NostrEvent; + if (event?.kind === NWC_RESPONSE_KIND) { + handleResponse(event).catch((err) => + logger.error("[nwc] Error handling response:", err), + ); + } + return; + } + + if (type === "OK") { + const eventId = msg[1] as string; + const success = msg[2] as boolean; + debugLog( + `Event ${eventId.slice(0, 8)}... ${success ? "accepted" : `rejected: ${msg[3]}`}`, + ); + return; + } + + if (type === "NOTICE") { + log(`Relay notice: ${msg[1]}`); + return; + } + + if (type === "EOSE") { + debugLog(`EOSE for subscription ${msg[1]}`); + return; + } + } + + // ── Connection lifecycle ─────────────────────────────────────── + + function doConnect(): Promise { + return new Promise((resolve, reject) => { + try { + log(`Connecting to NWC relay: ${relayUrl}`); + + const socket = new WebSocket(relayUrl); + const connectTimeout = setTimeout(() => { + socket.close(); + reject( + new Error( + `NWC relay connection timed out for ${relayUrl}`, + ), + ); + }, publishTimeoutMs); + + socket.onopen = () => { + clearTimeout(connectTimeout); + ws = socket; + connected = true; + reconnectAttempts = 0; + log(`Connected to NWC relay`); + + // Subscribe to response events (kind 23195) tagged for us + subscriptionId = `routstrd-nwc-${clientPubkeyHex.slice(0, 8)}`; + sendRaw([ + "REQ", + subscriptionId, + { + kinds: [NWC_RESPONSE_KIND], + "#p": [clientPubkeyHex], + since: Math.floor(Date.now() / 1000), + }, + ]); + debugLog(`Subscribed to responses: ${subscriptionId}`); + resolve(); + }; + + socket.onmessage = (event) => { + const data = + typeof event.data === "string" + ? event.data + : new TextDecoder().decode(event.data as ArrayBuffer); + handleRelayMessage(data); + }; + + socket.onclose = (event) => { + log( + `NWC relay disconnected: code=${event.code} reason="${event.reason}"`, + ); + connected = false; + ws = null; + + // Reject all pending calls + while (queue.length > 0) { + const call = queue.shift()!; + clearTimeout(call.timeout); + call.reject(new Error("NWC relay connection closed")); + } + + // Auto-reconnect + if (!stopReconnecting && reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = Math.min( + 1000 * Math.pow(2, reconnectAttempts), + 30000, + ); + log( + `Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`, + ); + setTimeout(() => { + doConnect().catch((err) => + logger.error("[nwc] Reconnect failed:", err), + ); + }, delay); + } else if (stopReconnecting) { + log("Reconnect disabled (explicit disconnect)"); + } else { + logger.error("[nwc] Max reconnect attempts reached"); + } + }; + + socket.onerror = (err) => { + logger.error("[nwc] WebSocket error:", err); + }; + } catch (error) { + reject(error); + } + }); + } + + // ── Public API ───────────────────────────────────────────────── + + return { + async connect() { + if (connected) { + log("Already connected"); + return; + } + stopReconnecting = false; + await doConnect(); + }, + + disconnect() { + stopReconnecting = true; + if (ws) { + if (subscriptionId) { + try { + sendRaw(["CLOSE", subscriptionId]); + } catch { + // ignore + } + subscriptionId = null; + } + ws.close(); + ws = null; + } + connected = false; + + while (queue.length > 0) { + const call = queue.shift()!; + clearTimeout(call.timeout); + call.reject(new Error("NWC client disconnected")); + } + + log("Disconnected from NWC relay"); + }, + + isConnected() { + return connected; + }, + + async getInfo() { + const response = await enqueueCall("get_info", {}); + const result = response.result || {}; + return { + alias: (result.alias as string) || "Unknown Wallet", + pubkey: (result.pubkey as string) || walletPubkey, + network: result.network as string | undefined, + methods: (result.methods as string[]) || [], + }; + }, + + async getBalance() { + const response = await enqueueCall("get_balance", {}); + const balance = (response.result?.balance as number) || 0; + return Math.floor(balance / 1000); // msats -> sats + }, + + async payInvoice(invoice, amount?) { + const params: Record = { invoice }; + if (amount !== undefined) { + params.amount = amount * 1000; // sats -> msats for NIP-47 + } + const response = await enqueueCall("pay_invoice", params); + const result = response.result || {}; + return { + preimage: (result.preimage as string) || "", + fees_paid: result.fees_paid as number | undefined, + }; + }, + + async makeInvoice(params) { + const response = await enqueueCall("make_invoice", { + amount: params.amount, + description: params.description || "", + }); + const result = response.result || {}; + return { + invoice: (result.invoice as string) || "", + payment_hash: (result.payment_hash as string) || "", + amount: (result.amount as number) || params.amount, + }; + }, + + async lookupInvoice(params) { + const lookupParams: Record = {}; + if (params.payment_hash) lookupParams.payment_hash = params.payment_hash; + if (params.invoice) lookupParams.invoice = params.invoice; + + const response = await enqueueCall("lookup_invoice", lookupParams); + const result = response.result; + if (!result) return null; + + return { + transaction_type: + (result.transaction_type as "incoming" | "outgoing") || "incoming", + invoice: result.invoice as string | undefined, + preimage: result.preimage as string | undefined, + payment_hash: (result.payment_hash as string) || "", + amount: (result.amount as number) || 0, + fees_paid: result.fees_paid as number | undefined, + settled_at: result.settled_at as number | undefined, + }; + }, + }; +} diff --git a/src/daemon/wallet/nwc-types.ts b/src/daemon/wallet/nwc-types.ts new file mode 100644 index 0000000..33b5c9a --- /dev/null +++ b/src/daemon/wallet/nwc-types.ts @@ -0,0 +1,123 @@ +// NIP-47 Nostr Wallet Connect types + +/** Parsed NWC connection string */ +export interface NwcConnectionString { + pubkey: string; // hex pubkey of the wallet service + relay: string; // WebSocket relay URL + secret: string; // 32-byte hex connection secret (wallet private key) +} + +/** NIP-47 request method names */ +export type NwcMethod = + | "pay_invoice" + | "get_balance" + | "make_invoice" + | "lookup_invoice" + | "get_info" + | "list_transactions" + | "sign_message"; + +/** NIP-47 request event content (kind 23194, encrypted) */ +export interface NwcRequest { + method: NwcMethod; + params: Record; +} + +/** NIP-47 response event content (kind 23195, encrypted) */ +export interface NwcResponse { + result_type: NwcMethod; + result?: Record; + error?: NwcError; +} + +/** NIP-47 error structure */ +export interface NwcError { + code: string; + message: string; +} + +/** Raw Nostr event shape used for relay communication */ +export interface NostrEvent { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; +} + +/** Filter for relay subscriptions */ +export interface NostrFilter { + kinds?: number[]; + authors?: string[]; + since?: number; + until?: number; + limit?: number; + [key: `#${string}`]: string[]; +} + +// NIP-47 event kinds +export const NWC_REQUEST_KIND = 23194; +export const NWC_RESPONSE_KIND = 23195; + +/** Balance result from get_balance */ +export interface GetBalanceResult { + balance: number; // in msats +} + +/** Pay invoice result */ +export interface PayInvoiceResult { + preimage: string; + fees_paid?: number; +} + +/** Make invoice params and result */ +export interface MakeInvoiceParams { + amount: number; // msats + description?: string; + description_hash?: string; + expiry?: number; +} + +export interface MakeInvoiceResult { + invoice: string; + payment_hash: string; + amount: number; + created_at: number; + expires_at: number; +} + +/** Get info result */ +export interface GetInfoResult { + alias?: string; + color?: string; + pubkey?: string; + network?: string; + block_height?: number; + block_hash?: string; + methods?: string[]; + notifications?: string[]; +} + +/** Lookup invoice result */ +export interface LookupInvoiceResult { + transaction_type: "incoming" | "outgoing"; + invoice?: string; + description?: string; + description_hash?: string; + preimage?: string; + payment_hash: string; + amount: number; // msats + fees_paid?: number; + created_at: number; + settled_at?: number; +} + +/** Get budget result */ +export interface GetBudgetResult { + total_budget?: number; + remaining_budget?: number; + used_budget?: number; + renewal_period?: string; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index ad38b35..253b3f9 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -7,6 +7,28 @@ export const DB_PATH = `${CONFIG_DIR}/routstr.db`; export const CONFIG_FILE = `${CONFIG_DIR}/config.json`; export const LOGS_DIR = `${CONFIG_DIR}/logs`; +/** NWC auto-refill configuration */ +export interface NwcAutoRefillConfig { + /** Whether auto-refill is enabled */ + enabled: boolean; + /** Refill when Cashu balance drops below this many sats */ + threshold: number; + /** Refill this many sats at a time */ + amount: number; + /** Minimum time between refills in milliseconds */ + cooldownMs: number; +} + +/** NWC configuration section */ +export interface NwcConfig { + /** NWC mode: "funding_source" = NWC funds the cocod Cashu wallet */ + mode: "funding_source" | "standalone"; + /** NWC connection string (nostr+walletconnect://...) */ + connectionString?: string; + /** Auto-refill settings */ + autoRefill?: NwcAutoRefillConfig; +} + export interface RoutstrdConfig { port: number; provider: string | null; @@ -16,6 +38,8 @@ export interface RoutstrdConfig { nsec?: string; /** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */ routstrPubkey?: string; + /** NWC integration configuration */ + nwc?: NwcConfig; } export const DEFAULT_CONFIG: RoutstrdConfig = {