nwc integration

This commit is contained in:
redshift
2026-05-19 01:25:27 +08:00
parent 3f14ae8084
commit cee4666f6e
9 changed files with 1967 additions and 4 deletions
+737
View File
@@ -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://<pubkey>?relay=<relay_url>&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 <token> │
│ routstrd wallet send cashu <amount> │
│ routstrd wallet receive bolt11 <amt> │
│ routstrd wallet send bolt11 <invoice> │
│ │
│ Daemon HTTP API: │
│ GET /wallet/balances │
│ POST /wallet/send │
│ POST /wallet/receive │
└────────┬──────────────────────────────┘
┌────────▼──────────────────────────────┐
│ daemon/wallet/index.ts │
│ createWalletAdapter() │
│ │
│ getBalances() → Record<mintUrl, sats>│
│ 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<paymentMethod, amount> │
│ 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 <connection-string>
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 <amount> # manually fund wallet from NWC
routstrd nwc pay-invoice <bolt11> # 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<void>;
isConnected(): boolean;
getBalance(): Promise<number>; // 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<typeof setInterval> | 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://<pk>?relay=... │
│ │ │
│ 2. Connect to relay (WebSocket) │
│────────────────────▶│ │
│ │ │
│ 3. Subscribe to NIP-47 responses │
│ (kind 23195, p=<my-pubkey>) │
│────────────────────▶│ │
│ │ │
│ 4. NIP-47 request event │
│ (kind 23194, encrypted, p=<wallet-pk>) │
│────────────────────▶│ │
│ │ 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 |
+117
View File
@@ -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<string>((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 <amount>")
.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 <sats>",
"Refill when Cashu balance drops below this many sats",
"500",
)
.option("--amount <sats>", "Refill this many sats at a time", "1000")
.option(
"--cooldown <seconds>",
"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")
+133
View File
@@ -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 =
+61
View File
@@ -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<void> {
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<void> {
server.on("close", () => {
stopModelRefreshJob();
stopRefundJob();
if (nwcCleanup) nwcCleanup();
});
server.listen(port, async () => {
+107
View File
@@ -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<typeof setInterval> | null = null;
let checkInProgress = false;
async function checkAndRefill(): Promise<void> {
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<number>(
(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");
};
}
+120 -4
View File
@@ -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<string> {
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(),
+545
View File
@@ -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<void>;
disconnect(): void;
isConnected(): boolean;
getInfo(): Promise<{
alias: string;
pubkey: string;
network?: string;
methods: string[];
}>;
getBalance(): Promise<number>; // 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<string, unknown>;
resolve: (response: NwcResponse) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
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<void> {
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<void> {
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<string, unknown>,
): Promise<NwcResponse> {
if (!connected || !ws) {
return Promise.reject(new Error("NWC client not connected"));
}
return new Promise<NwcResponse>((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<void> {
return new Promise<void>((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<string, unknown> = { 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<string, unknown> = {};
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,
};
},
};
}
+123
View File
@@ -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<string, unknown>;
}
/** NIP-47 response event content (kind 23195, encrypted) */
export interface NwcResponse {
result_type: NwcMethod;
result?: Record<string, unknown>;
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;
}
+24
View File
@@ -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 = {