mirror of
https://github.com/Routstr/routstrd.git
synced 2026-08-12 13:03:21 +00:00
chore: remove legacy NWC client, fix auto-refill getter
This commit is contained in:
@@ -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 |
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user