mirror of
https://github.com/Routstr/routstrd.git
synced 2026-08-09 03:44:38 +00:00
nwc integration
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user