chore: remove legacy NWC client, fix auto-refill getter

This commit is contained in:
redshift
2026-05-23 23:03:48 +08:00
parent c98709e383
commit 92ca9a7cb0
5 changed files with 2 additions and 1369 deletions
-737
View File
@@ -1,737 +0,0 @@
# 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 |
+2 -2
View File
@@ -211,9 +211,9 @@ export async function createWalletAdapter(
}
},
/** Get the auto-refill config */
/** Get the current auto-refill config, re-reading from the getter if available */
getAutoRefillConfig(): AutoRefillConfig | undefined {
return options.autoRefill;
return options.getAutoRefillConfig?.() ?? options.autoRefill;
},
async sendToken(mintUrl: string, amount: number): Promise<string> {
const maxRetries = 3;
-462
View File
@@ -1,462 +0,0 @@
// NIP-47 NWC (Nostr Wallet Connect) client for routstrd
// Uses nostr-tools for key management and encryption,
// Bun's native WebSocket for relay communication.
// Encryption: uses NIP-04 (default NIP-47 scheme).
import {
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;
clientSecretKey?: string;
replyTimeoutMs?: number;
publishTimeoutMs?: number;
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>;
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 ───────────────────────────────────────────
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;
const clientSecretKey = options.clientSecretKey
? Buffer.from(options.clientSecretKey, "hex")
: Buffer.from(parsed.secret, "hex");
const clientPubkeyHex = getPublicKey(clientSecretKey);
// ── State ──────────────────────────────────────────────────────
let ws: WebSocket | null = null;
let connected = false;
let subscriptionReady = false;
let subscriptionId: string | null = null;
let reconnectAttempts = 0;
let stopReconnecting = false;
const queue: QueuedCall[] = [];
let sending = false;
// ── Logging ────────────────────────────────────────────────────
function log(...args: unknown[]) { logger.log("[nwc]", ...args); }
function debugLog(...args: unknown[]) { logger.debug("[nwc]", ...args); }
// ── Encryption helpers ─────────────────────────────────────────
function encryptPayload(payload: string): string {
// nostr-tools v2 nip04.encrypt is sync but typed as Promise-like
return nip04.encrypt(clientSecretKey, walletPubkey, payload) as unknown as string;
}
function decryptPayload(payload: string): string {
return nip04.decrypt(clientSecretKey, walletPubkey, payload) as unknown as string;
}
// ── Event 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));
}
function buildRequestTags(): string[][] {
return [["p", walletPubkey]];
}
// ── Queue processing ───────────────────────────────────────────
function sendNextFromQueue(): 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: ${queue.length})`);
const encrypted = encryptPayload(requestJson);
const event = createSignedEvent(NWC_REQUEST_KIND, encrypted, buildRequestTags());
sendRaw(["EVENT", event]);
debugLog(`Published ${call.method} req ${event.id.slice(0, 8)}...`);
} catch (error) {
const failed = queue.shift()!;
clearTimeout(failed.timeout);
failed.reject(error instanceof Error ? error : new Error(String(error)));
} finally {
sending = false;
}
}
function handleResponse(event: NostrEvent): 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 but no pending calls"); return; }
try {
debugLog(`Decrypting response ${event.id.slice(0, 8)}...`);
const decrypted = decryptPayload(event.content);
debugLog(`Decrypted: ${decrypted.slice(0, 200)}`);
const response = JSON.parse(decrypted) as NwcResponse;
const resultType = response.result_type;
let call: QueuedCall | undefined;
if (resultType) {
const idx = queue.findIndex((c) => c.method === resultType);
if (idx >= 0) {
call = queue[idx]; queue.splice(idx, 1);
debugLog(`Matched response to ${resultType} (idx ${idx})`);
} else {
debugLog(`Ignoring stale response for ${resultType}`);
return;
}
} else {
debugLog("No result_type, using queue head");
call = queue.shift()!;
}
if (!call) return;
clearTimeout(call.timeout);
if (response.error) {
call.reject(new Error(
`NWC error (${response.error.code}): ${response.error.message}`));
} else {
call.resolve(response);
}
} catch (error) {
const failed = queue.shift();
if (failed) {
clearTimeout(failed.timeout);
failed.reject(new Error(
`Failed to parse NWC response: ${(error as Error).message}`));
}
}
sendNextFromQueue();
}
// ── Enqueue ────────────────────────────────────────────────────
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);
return;
}
if (type === "OK") {
const success = msg[2] as boolean;
debugLog(`Event ${(msg[1]as string).slice(0,8)}... ${success ? "accepted" : "rejected: "+msg[3]}`);
return;
}
if (type === "NOTICE") { log(`Relay notice: ${msg[1]}`); return; }
if (type === "EOSE") {
if (msg[1] === subscriptionId) subscriptionReady = true;
debugLog(`EOSE for sub ${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;
subscriptionReady = false;
reconnectAttempts = 0;
log("Connected to NWC relay");
// Subscribe to response events tagged for us
subscriptionId = `routstrd-nwc-${clientPubkeyHex.slice(0, 8)}`;
sendRaw(["REQ", subscriptionId, {
kinds: [NWC_RESPONSE_KIND], "#p": [clientPubkeyHex],
}]);
debugLog(`Subscribed: ${subscriptionId}`);
// Wait for EOSE to confirm subscription is active
const waitForReady = () => {
if (subscriptionReady || !connected) resolve();
else setTimeout(waitForReady, 50);
};
// Safety timeout: resolve anyway after 5s
setTimeout(() => {
if (!subscriptionReady && connected) {
log("EOSE timeout, proceeding");
resolve();
}
}, 5000);
waitForReady();
};
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; subscriptionReady = false; ws = null;
while (queue.length > 0) {
const call = queue.shift()!;
clearTimeout(call.timeout);
call.reject(new Error("NWC relay connection closed"));
}
if (!stopReconnecting && reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
log(`Reconnecting in ${delay}ms (${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(() => {
doConnect().catch((err) => logger.error("[nwc] Reconnect failed:", err));
}, delay);
} else if (stopReconnecting) {
log("Reconnect disabled");
} 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 {} subscriptionId = null; }
ws.close(); ws = null;
}
connected = false; subscriptionReady = false;
while (queue.length > 0) {
const call = queue.shift()!;
clearTimeout(call.timeout);
call.reject(new Error("NWC client disconnected"));
}
log("Disconnected");
},
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", {});
return Math.floor(((response.result?.balance as number) || 0) / 1000);
},
async payInvoice(invoice, amount?) {
const params: Record<string, unknown> = { invoice };
if (amount !== undefined) params.amount = amount * 1000;
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 * 1000,
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);
if (!response.result) return null;
const result = response.result;
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
@@ -1,123 +0,0 @@
// 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;
}
-45
View File
@@ -1,45 +0,0 @@
/**
* Standalone NWC test — create and pay a Lightning invoice.
* Usage: bun test_nwc.ts [nostr+walletconnect://...]
* Needs NWC_CONNECTION_STRING in .env or as CLI arg.
*/
import { createNwcClient, parseConnectionString } from "./src/daemon/wallet/nwc-client";
let cs = process.argv[2] || process.env.NWC_CONNECTION_STRING;
if (!cs) {
try {
for (const l of (await Bun.file(".env").text()).split("\n")) {
const [k, ...v] = l.trim().split("=");
if (k === "NWC_CONNECTION_STRING") { cs = v.join("="); break; }
}
} catch {}
}
if (!cs) {
console.error("❌ Set NWC_CONNECTION_STRING in .env or pass as CLI arg");
process.exit(1);
}
const client = createNwcClient({ connectionString: cs, replyTimeoutMs: 30000 });
try {
console.log("🔌 Connecting...");
await client.connect();
console.log("✅ Connected\n");
console.log("📄 make_invoice (1 sat)");
const inv = await client.makeInvoice({ amount: 1, description: "NWC test" });
console.log(` hash : ${inv.payment_hash}`);
console.log(` invoice: ${inv.invoice.slice(0, 60)}...`);
console.log("\n💸 pay_invoice");
const pay = await client.payInvoice(inv.invoice, 1);
console.log(`✅ Paid — preimage: ${pay.preimage.slice(0, 16)}...`);
console.log("\n✅ Done");
} catch (err) {
console.error(`\n❌ ${(err as Error).message}`);
process.exit(1);
} finally {
client.disconnect();
}