lightning-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/lightning-agent.js +133 -0
- package/lib/index.js +10 -0
- package/lib/wallet.js +331 -0
- package/package.json +16 -0
- package/test.js +138 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeletor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# ⚡ lightning-agent
|
|
2
|
+
|
|
3
|
+
Lightning payments for AI agents. Two functions: charge and pay.
|
|
4
|
+
|
|
5
|
+
A tiny SDK that gives any AI agent the ability to send and receive Bitcoin Lightning payments using [Nostr Wallet Connect (NWC)](https://nwc.dev). No browser, no UI — just code. Connect your agent to an NWC-compatible wallet (Alby Hub, Mutiny, etc.) and start transacting in sats.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install lightning-agent
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
const { createWallet } = require('lightning-agent');
|
|
17
|
+
|
|
18
|
+
const wallet = createWallet('nostr+walletconnect://...');
|
|
19
|
+
|
|
20
|
+
// Check balance
|
|
21
|
+
const { balanceSats } = await wallet.getBalance();
|
|
22
|
+
console.log(`Balance: ${balanceSats} sats`);
|
|
23
|
+
|
|
24
|
+
// Create an invoice (get paid)
|
|
25
|
+
const { invoice, paymentHash } = await wallet.createInvoice({
|
|
26
|
+
amountSats: 50,
|
|
27
|
+
description: 'Text generation query'
|
|
28
|
+
});
|
|
29
|
+
console.log(`Pay me: ${invoice}`);
|
|
30
|
+
|
|
31
|
+
// Wait for payment
|
|
32
|
+
const { paid } = await wallet.waitForPayment(paymentHash, { timeoutMs: 60000 });
|
|
33
|
+
|
|
34
|
+
// Pay an invoice (spend)
|
|
35
|
+
const { preimage } = await wallet.payInvoice(someInvoice);
|
|
36
|
+
console.log(`Paid! Preimage: ${preimage}`);
|
|
37
|
+
|
|
38
|
+
// Done
|
|
39
|
+
wallet.close();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## API Reference
|
|
43
|
+
|
|
44
|
+
### `createWallet(nwcUrl?)`
|
|
45
|
+
|
|
46
|
+
Create a wallet instance. Pass an NWC URL directly or set the `NWC_URL` environment variable.
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
const wallet = createWallet('nostr+walletconnect://...');
|
|
50
|
+
// or
|
|
51
|
+
process.env.NWC_URL = 'nostr+walletconnect://...';
|
|
52
|
+
const wallet = createWallet();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `wallet.getBalance(opts?)`
|
|
56
|
+
|
|
57
|
+
Get the wallet balance.
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
const { balanceSats, balanceMsats } = await wallet.getBalance();
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Options:** `{ timeoutMs: 15000 }`
|
|
64
|
+
|
|
65
|
+
### `wallet.createInvoice(opts)`
|
|
66
|
+
|
|
67
|
+
Create a Lightning invoice (receive payment).
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
const { invoice, paymentHash, amountSats } = await wallet.createInvoice({
|
|
71
|
+
amountSats: 100,
|
|
72
|
+
description: 'API call fee',
|
|
73
|
+
expiry: 3600, // optional, seconds
|
|
74
|
+
timeoutMs: 15000 // optional
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `wallet.payInvoice(invoice, opts?)`
|
|
79
|
+
|
|
80
|
+
Pay a Lightning invoice.
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
const { preimage, paymentHash } = await wallet.payInvoice('lnbc50u1p...');
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Options:** `{ timeoutMs: 30000 }`
|
|
87
|
+
|
|
88
|
+
### `wallet.waitForPayment(paymentHash, opts?)`
|
|
89
|
+
|
|
90
|
+
Poll until an invoice is paid (or timeout).
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
const { paid, preimage, settledAt } = await wallet.waitForPayment(hash, {
|
|
94
|
+
timeoutMs: 60000, // total wait (default 60s)
|
|
95
|
+
pollIntervalMs: 2000 // poll frequency (default 2s)
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `wallet.decodeInvoice(invoice)`
|
|
100
|
+
|
|
101
|
+
Decode a bolt11 invoice offline (no wallet connection needed). Extracts amount and network.
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
const { amountSats, network } = wallet.decodeInvoice('lnbc50u1p...');
|
|
105
|
+
// { amountSats: 5000, network: 'mainnet', description: null, paymentHash: null }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `wallet.close()`
|
|
109
|
+
|
|
110
|
+
Close the relay connection. Call when done.
|
|
111
|
+
|
|
112
|
+
### `decodeBolt11(invoice)`
|
|
113
|
+
|
|
114
|
+
Standalone bolt11 decoder (no wallet instance needed).
|
|
115
|
+
|
|
116
|
+
```javascript
|
|
117
|
+
const { decodeBolt11 } = require('lightning-agent');
|
|
118
|
+
const { amountSats } = decodeBolt11('lnbc210n1p...');
|
|
119
|
+
// amountSats = 21
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `parseNwcUrl(url)`
|
|
123
|
+
|
|
124
|
+
Parse an NWC URL into its components.
|
|
125
|
+
|
|
126
|
+
```javascript
|
|
127
|
+
const { parseNwcUrl } = require('lightning-agent');
|
|
128
|
+
const { walletPubkey, relay, secret } = parseNwcUrl('nostr+walletconnect://...');
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## CLI
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Set your NWC URL
|
|
135
|
+
export NWC_URL="nostr+walletconnect://..."
|
|
136
|
+
|
|
137
|
+
# Check balance
|
|
138
|
+
lightning-agent balance
|
|
139
|
+
|
|
140
|
+
# Create an invoice for 50 sats
|
|
141
|
+
lightning-agent invoice 50 "Text generation query"
|
|
142
|
+
|
|
143
|
+
# Pay an invoice
|
|
144
|
+
lightning-agent pay lnbc50u1p...
|
|
145
|
+
|
|
146
|
+
# Decode an invoice (offline)
|
|
147
|
+
lightning-agent decode lnbc50u1p...
|
|
148
|
+
|
|
149
|
+
# Wait for a payment
|
|
150
|
+
lightning-agent wait <payment_hash> [timeout_ms]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Getting an NWC URL
|
|
154
|
+
|
|
155
|
+
You need a Nostr Wallet Connect URL from a compatible wallet:
|
|
156
|
+
|
|
157
|
+
- **[Alby Hub](https://albyhub.com)** — Self-hosted Lightning node with NWC. Recommended for agents.
|
|
158
|
+
- **[Mutiny Wallet](https://mutinywallet.com)** — Mobile-first with NWC support.
|
|
159
|
+
- **[Coinos](https://coinos.io)** — Web wallet with NWC.
|
|
160
|
+
|
|
161
|
+
The URL looks like: `nostr+walletconnect://<wallet_pubkey>?relay=wss://...&secret=<hex>`
|
|
162
|
+
|
|
163
|
+
## How It Works
|
|
164
|
+
|
|
165
|
+
lightning-agent uses the [NWC protocol (NIP-47)](https://github.com/nostr-protocol/nips/blob/master/47.md):
|
|
166
|
+
|
|
167
|
+
1. Your agent signs NWC requests (kind 23194) with the secret from the NWC URL
|
|
168
|
+
2. Requests are encrypted with NIP-04 and sent to the wallet's relay
|
|
169
|
+
3. The wallet service processes the request and returns an encrypted response (kind 23195)
|
|
170
|
+
4. All communication happens over Nostr relays — no direct connection to the wallet needed
|
|
171
|
+
|
|
172
|
+
## Design Philosophy
|
|
173
|
+
|
|
174
|
+
This is built for AI agents, not humans:
|
|
175
|
+
|
|
176
|
+
- **Minimal deps** — just `nostr-tools` and `ws`
|
|
177
|
+
- **No UI** — pure code, works in any Node.js environment
|
|
178
|
+
- **Connection reuse** — maintains a single relay connection across requests
|
|
179
|
+
- **Timeouts everywhere** — agents can't afford to hang
|
|
180
|
+
- **Simple API** — `createInvoice` to charge, `payInvoice` to pay
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createWallet, decodeBolt11 } = require('../lib');
|
|
5
|
+
|
|
6
|
+
const USAGE = `
|
|
7
|
+
lightning-agent — Lightning payments for AI agents
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
lightning-agent balance Check wallet balance
|
|
11
|
+
lightning-agent invoice <sats> [description] Create an invoice
|
|
12
|
+
lightning-agent pay <bolt11> Pay an invoice
|
|
13
|
+
lightning-agent decode <bolt11> Decode an invoice (offline)
|
|
14
|
+
lightning-agent wait <payment_hash> [timeout] Wait for payment
|
|
15
|
+
|
|
16
|
+
Environment:
|
|
17
|
+
NWC_URL Nostr Wallet Connect URL (nostr+walletconnect://...)
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
export NWC_URL="nostr+walletconnect://..."
|
|
21
|
+
lightning-agent balance
|
|
22
|
+
lightning-agent invoice 50 "AI query fee"
|
|
23
|
+
lightning-agent pay lnbc50u1p...
|
|
24
|
+
lightning-agent decode lnbc50u1p...
|
|
25
|
+
`.trim();
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const command = args[0];
|
|
30
|
+
|
|
31
|
+
if (!command || command === '--help' || command === '-h') {
|
|
32
|
+
console.log(USAGE);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// decode is offline — no wallet needed
|
|
37
|
+
if (command === 'decode') {
|
|
38
|
+
const invoice = args[1];
|
|
39
|
+
if (!invoice) {
|
|
40
|
+
console.error('Error: bolt11 invoice required');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const details = decodeBolt11(invoice);
|
|
45
|
+
console.log(JSON.stringify(details, null, 2));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('Error:', err.message);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// All other commands need a wallet
|
|
54
|
+
if (!process.env.NWC_URL) {
|
|
55
|
+
console.error('Error: NWC_URL environment variable not set');
|
|
56
|
+
console.error('Set it: export NWC_URL="nostr+walletconnect://..."');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let wallet;
|
|
61
|
+
try {
|
|
62
|
+
wallet = createWallet(process.env.NWC_URL);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('Error creating wallet:', err.message);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
switch (command) {
|
|
70
|
+
case 'balance': {
|
|
71
|
+
const { balanceSats } = await wallet.getBalance();
|
|
72
|
+
console.log(`${balanceSats} sats`);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'invoice': {
|
|
77
|
+
const sats = parseInt(args[1], 10);
|
|
78
|
+
if (!sats || sats <= 0) {
|
|
79
|
+
console.error('Error: amount in sats required (positive integer)');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const description = args.slice(2).join(' ') || undefined;
|
|
83
|
+
const result = await wallet.createInvoice({ amountSats: sats, description });
|
|
84
|
+
console.log(result.invoice);
|
|
85
|
+
if (result.paymentHash) {
|
|
86
|
+
console.error(`Payment hash: ${result.paymentHash}`);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'pay': {
|
|
92
|
+
const invoice = args[1];
|
|
93
|
+
if (!invoice) {
|
|
94
|
+
console.error('Error: bolt11 invoice required');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const result = await wallet.payInvoice(invoice);
|
|
98
|
+
console.log(`Paid! Preimage: ${result.preimage}`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case 'wait': {
|
|
103
|
+
const paymentHash = args[1];
|
|
104
|
+
if (!paymentHash) {
|
|
105
|
+
console.error('Error: payment_hash required');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const timeoutMs = parseInt(args[2], 10) || 60000;
|
|
109
|
+
console.error(`Waiting for payment (timeout: ${timeoutMs}ms)...`);
|
|
110
|
+
const result = await wallet.waitForPayment(paymentHash, { timeoutMs });
|
|
111
|
+
if (result.paid) {
|
|
112
|
+
console.log(`Paid! Preimage: ${result.preimage}`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log('Not paid (timed out)');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
default:
|
|
121
|
+
console.error(`Unknown command: ${command}`);
|
|
122
|
+
console.log(USAGE);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error('Error:', err.message);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
} finally {
|
|
129
|
+
wallet.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main();
|
package/lib/index.js
ADDED
package/lib/wallet.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { finalizeEvent, getPublicKey } = require('nostr-tools');
|
|
4
|
+
const { Relay } = require('nostr-tools/relay');
|
|
5
|
+
const nip04 = require('nostr-tools/nip04');
|
|
6
|
+
|
|
7
|
+
// ─── Bolt11 minimal decoder ───
|
|
8
|
+
|
|
9
|
+
const MULTIPLIERS = {
|
|
10
|
+
m: 1e-3, // milli-BTC
|
|
11
|
+
u: 1e-6, // micro-BTC
|
|
12
|
+
n: 1e-9, // nano-BTC
|
|
13
|
+
p: 1e-12 // pico-BTC
|
|
14
|
+
};
|
|
15
|
+
const BTC_TO_SATS = 1e8;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Decode a bolt11 invoice's human-readable part to extract amount in sats.
|
|
19
|
+
* Format: ln<network><amount><multiplier>
|
|
20
|
+
* Examples: lnbc50u = 50 micro-BTC = 5000 sats
|
|
21
|
+
* lnbc210n = 210 nano-BTC = 21 sats
|
|
22
|
+
* lnbc1m = 1 milli-BTC = 100000 sats
|
|
23
|
+
*/
|
|
24
|
+
function decodeBolt11(invoice) {
|
|
25
|
+
if (!invoice || typeof invoice !== 'string') {
|
|
26
|
+
throw new Error('Invalid bolt11 invoice');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lower = invoice.toLowerCase();
|
|
30
|
+
|
|
31
|
+
// Find the separator: last '1' before the data part
|
|
32
|
+
// The human-readable part is everything before the last '1' that's followed by data
|
|
33
|
+
// Bolt11 uses bech32: <hrp>1<data><checksum>
|
|
34
|
+
const lastOneIdx = lower.lastIndexOf('1');
|
|
35
|
+
if (lastOneIdx < 4) throw new Error('Invalid bolt11: no separator found');
|
|
36
|
+
|
|
37
|
+
const hrp = lower.substring(0, lastOneIdx);
|
|
38
|
+
|
|
39
|
+
// Parse HRP: ln + network prefix + amount
|
|
40
|
+
// Network prefixes: bc (mainnet), tb (testnet), bcrt (regtest), tbs (signet)
|
|
41
|
+
let rest;
|
|
42
|
+
if (hrp.startsWith('lnbc')) {
|
|
43
|
+
rest = hrp.substring(4);
|
|
44
|
+
} else if (hrp.startsWith('lntbs')) {
|
|
45
|
+
rest = hrp.substring(5);
|
|
46
|
+
} else if (hrp.startsWith('lntb')) {
|
|
47
|
+
rest = hrp.substring(4);
|
|
48
|
+
} else if (hrp.startsWith('lnbcrt')) {
|
|
49
|
+
rest = hrp.substring(6);
|
|
50
|
+
} else {
|
|
51
|
+
throw new Error('Unknown bolt11 network prefix');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let amountSats = null;
|
|
55
|
+
|
|
56
|
+
if (rest.length > 0) {
|
|
57
|
+
// Last char might be a multiplier
|
|
58
|
+
const lastChar = rest[rest.length - 1];
|
|
59
|
+
if (MULTIPLIERS[lastChar] !== undefined) {
|
|
60
|
+
const numStr = rest.substring(0, rest.length - 1);
|
|
61
|
+
const num = parseFloat(numStr);
|
|
62
|
+
if (isNaN(num)) throw new Error('Invalid bolt11 amount: ' + numStr);
|
|
63
|
+
const btcAmount = num * MULTIPLIERS[lastChar];
|
|
64
|
+
amountSats = Math.round(btcAmount * BTC_TO_SATS);
|
|
65
|
+
} else {
|
|
66
|
+
// No multiplier — amount is in BTC
|
|
67
|
+
const num = parseFloat(rest);
|
|
68
|
+
if (isNaN(num)) throw new Error('Invalid bolt11 amount: ' + rest);
|
|
69
|
+
amountSats = Math.round(num * BTC_TO_SATS);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// If rest is empty, it's a zero-amount invoice
|
|
73
|
+
|
|
74
|
+
// Try to extract description from tagged fields (best effort)
|
|
75
|
+
// Tagged fields are in the data part after the separator, bech32-decoded
|
|
76
|
+
// This is complex — we do minimal extraction
|
|
77
|
+
let description = null;
|
|
78
|
+
let paymentHash = null;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const dataPart = lower.substring(lastOneIdx + 1);
|
|
82
|
+
// First 7 chars = timestamp (bech32 chars, 35 bits)
|
|
83
|
+
// Then tagged fields follow
|
|
84
|
+
// Each tag: 1 char type + 2 chars data length + data
|
|
85
|
+
// We'd need full bech32 decoding for this — skip for now
|
|
86
|
+
// Payment hash and description require full bech32 decode
|
|
87
|
+
} catch (_) { /* best effort */ }
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
amountSats,
|
|
91
|
+
description,
|
|
92
|
+
paymentHash,
|
|
93
|
+
network: hrp.startsWith('lntb') ? 'testnet' :
|
|
94
|
+
hrp.startsWith('lnbcrt') ? 'regtest' :
|
|
95
|
+
hrp.startsWith('lntbs') ? 'signet' : 'mainnet'
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── NWC URL parser ───
|
|
100
|
+
|
|
101
|
+
function parseNwcUrl(nwcUrl) {
|
|
102
|
+
if (!nwcUrl || typeof nwcUrl !== 'string') {
|
|
103
|
+
throw new Error('NWC URL is required');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!nwcUrl.startsWith('nostr+walletconnect://')) {
|
|
107
|
+
throw new Error('Invalid NWC URL: must start with nostr+walletconnect://');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const url = new URL(nwcUrl);
|
|
111
|
+
const walletPubkey = url.hostname || url.pathname.replace('//', '');
|
|
112
|
+
const relay = url.searchParams.get('relay');
|
|
113
|
+
const secret = url.searchParams.get('secret');
|
|
114
|
+
|
|
115
|
+
if (!walletPubkey || walletPubkey.length !== 64) {
|
|
116
|
+
throw new Error('Invalid NWC URL: bad wallet pubkey');
|
|
117
|
+
}
|
|
118
|
+
if (!relay) {
|
|
119
|
+
throw new Error('Invalid NWC URL: missing relay parameter');
|
|
120
|
+
}
|
|
121
|
+
if (!secret || secret.length !== 64) {
|
|
122
|
+
throw new Error('Invalid NWC URL: missing or invalid secret');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { walletPubkey, relay, secret };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Wallet class ───
|
|
129
|
+
|
|
130
|
+
class NWCWallet {
|
|
131
|
+
constructor(nwcUrl) {
|
|
132
|
+
const parsed = parseNwcUrl(nwcUrl);
|
|
133
|
+
this.walletPubkey = parsed.walletPubkey;
|
|
134
|
+
this.relayUrl = parsed.relay;
|
|
135
|
+
this.secret = parsed.secret;
|
|
136
|
+
this.secretBytes = Uint8Array.from(Buffer.from(parsed.secret, 'hex'));
|
|
137
|
+
this.clientPubkey = getPublicKey(this.secretBytes);
|
|
138
|
+
|
|
139
|
+
this._closed = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Core NWC request ───
|
|
143
|
+
// Uses a fresh relay connection per request for reliability.
|
|
144
|
+
// NWC relays (especially Alby) handle connection reuse poorly.
|
|
145
|
+
|
|
146
|
+
async _nwcRequest(method, params = {}, timeoutMs = 15000) {
|
|
147
|
+
const payload = JSON.stringify({ method, params });
|
|
148
|
+
const encrypted = await nip04.encrypt(this.secretBytes, this.walletPubkey, payload);
|
|
149
|
+
|
|
150
|
+
const event = finalizeEvent({
|
|
151
|
+
kind: 23194,
|
|
152
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
153
|
+
tags: [['p', this.walletPubkey]],
|
|
154
|
+
content: encrypted
|
|
155
|
+
}, this.secretBytes);
|
|
156
|
+
|
|
157
|
+
// Fresh connection per request — more reliable than reuse
|
|
158
|
+
const relay = await Relay.connect(this.relayUrl);
|
|
159
|
+
await relay.publish(event);
|
|
160
|
+
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
sub.close();
|
|
164
|
+
try { relay.close(); } catch (_) {}
|
|
165
|
+
reject(new Error(`NWC request timed out after ${timeoutMs}ms`));
|
|
166
|
+
}, timeoutMs);
|
|
167
|
+
|
|
168
|
+
const sub = relay.subscribe(
|
|
169
|
+
[{ kinds: [23195], '#e': [event.id], limit: 1 }],
|
|
170
|
+
{
|
|
171
|
+
onevent: async (e) => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
try {
|
|
174
|
+
const decrypted = await nip04.decrypt(this.secretBytes, e.pubkey, e.content);
|
|
175
|
+
const parsed = JSON.parse(decrypted);
|
|
176
|
+
|
|
177
|
+
if (parsed.error) {
|
|
178
|
+
reject(new Error(`NWC error (${parsed.error.code}): ${parsed.error.message}`));
|
|
179
|
+
} else {
|
|
180
|
+
resolve(parsed);
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reject(new Error('NWC decrypt failed: ' + err.message));
|
|
184
|
+
}
|
|
185
|
+
sub.close();
|
|
186
|
+
try { relay.close(); } catch (_) {}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Public API ───
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get wallet balance in sats.
|
|
197
|
+
*/
|
|
198
|
+
async getBalance(opts = {}) {
|
|
199
|
+
const timeoutMs = opts.timeoutMs || 15000;
|
|
200
|
+
const res = await this._nwcRequest('get_balance', {}, timeoutMs);
|
|
201
|
+
// NWC returns balance in millisats
|
|
202
|
+
const balanceMsats = res.result?.balance || 0;
|
|
203
|
+
return {
|
|
204
|
+
balanceSats: Math.round(balanceMsats / 1000),
|
|
205
|
+
balanceMsats
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create a Lightning invoice (get paid).
|
|
211
|
+
* @param {object} opts
|
|
212
|
+
* @param {number} opts.amountSats - Amount in satoshis
|
|
213
|
+
* @param {string} [opts.description] - Invoice description
|
|
214
|
+
* @param {number} [opts.timeoutMs] - Request timeout
|
|
215
|
+
*/
|
|
216
|
+
async createInvoice(opts = {}) {
|
|
217
|
+
if (!opts.amountSats || opts.amountSats <= 0) {
|
|
218
|
+
throw new Error('amountSats is required and must be positive');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const params = {
|
|
222
|
+
amount: opts.amountSats * 1000, // NWC uses millisats
|
|
223
|
+
};
|
|
224
|
+
if (opts.description) params.description = opts.description;
|
|
225
|
+
if (opts.expiry) params.expiry = opts.expiry;
|
|
226
|
+
|
|
227
|
+
const timeoutMs = opts.timeoutMs || 15000;
|
|
228
|
+
const res = await this._nwcRequest('make_invoice', params, timeoutMs);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
invoice: res.result?.invoice,
|
|
232
|
+
paymentHash: res.result?.payment_hash,
|
|
233
|
+
description: res.result?.description || opts.description || null,
|
|
234
|
+
amountSats: opts.amountSats
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Pay a Lightning invoice.
|
|
240
|
+
* @param {string} invoice - Bolt11 invoice string
|
|
241
|
+
* @param {object} [opts]
|
|
242
|
+
* @param {number} [opts.timeoutMs] - Request timeout (default 30s for payments)
|
|
243
|
+
*/
|
|
244
|
+
async payInvoice(invoice, opts = {}) {
|
|
245
|
+
if (!invoice || typeof invoice !== 'string') {
|
|
246
|
+
throw new Error('invoice is required');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const timeoutMs = opts.timeoutMs || 30000; // Longer default for payments
|
|
250
|
+
const res = await this._nwcRequest('pay_invoice', { invoice }, timeoutMs);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
preimage: res.result?.preimage,
|
|
254
|
+
paymentHash: res.result?.payment_hash || null
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Wait for an invoice to be paid by polling lookup_invoice.
|
|
260
|
+
* @param {string} paymentHash - Payment hash to check
|
|
261
|
+
* @param {object} [opts]
|
|
262
|
+
* @param {number} [opts.timeoutMs] - Total wait timeout (default 60s)
|
|
263
|
+
* @param {number} [opts.pollIntervalMs] - Poll interval (default 2s)
|
|
264
|
+
*/
|
|
265
|
+
async waitForPayment(paymentHash, opts = {}) {
|
|
266
|
+
if (!paymentHash) throw new Error('paymentHash is required');
|
|
267
|
+
|
|
268
|
+
const timeoutMs = opts.timeoutMs || 60000;
|
|
269
|
+
const pollIntervalMs = opts.pollIntervalMs || 2000;
|
|
270
|
+
const start = Date.now();
|
|
271
|
+
|
|
272
|
+
while (Date.now() - start < timeoutMs) {
|
|
273
|
+
try {
|
|
274
|
+
const res = await this._nwcRequest('lookup_invoice', { payment_hash: paymentHash }, 10000);
|
|
275
|
+
// Check if settled
|
|
276
|
+
if (res.result?.settled_at || res.result?.preimage) {
|
|
277
|
+
return {
|
|
278
|
+
paid: true,
|
|
279
|
+
preimage: res.result.preimage || null,
|
|
280
|
+
settledAt: res.result.settled_at || null
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
// lookup_invoice might not be supported — continue polling
|
|
285
|
+
if (err.message.includes('NOT_IMPLEMENTED')) {
|
|
286
|
+
throw new Error('lookup_invoice not supported by this wallet');
|
|
287
|
+
}
|
|
288
|
+
// Other errors: retry
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Wait before next poll
|
|
292
|
+
await new Promise(r => setTimeout(r, pollIntervalMs));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { paid: false, preimage: null, settledAt: null };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Decode a bolt11 invoice (offline, no NWC needed).
|
|
300
|
+
* @param {string} invoice - Bolt11 invoice string
|
|
301
|
+
*/
|
|
302
|
+
decodeInvoice(invoice) {
|
|
303
|
+
return decodeBolt11(invoice);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Close the relay connection.
|
|
308
|
+
*/
|
|
309
|
+
close() {
|
|
310
|
+
this._closed = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── Factory function ───
|
|
315
|
+
|
|
316
|
+
function createWallet(nwcUrl) {
|
|
317
|
+
const url = nwcUrl || process.env.NWC_URL;
|
|
318
|
+
if (!url) {
|
|
319
|
+
throw new Error('NWC URL required. Pass it directly or set NWC_URL env var.');
|
|
320
|
+
}
|
|
321
|
+
return new NWCWallet(url);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Exports ───
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
createWallet,
|
|
328
|
+
parseNwcUrl,
|
|
329
|
+
decodeBolt11,
|
|
330
|
+
NWCWallet
|
|
331
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lightning-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightning payments for AI agents. Two functions: charge and pay.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lightning-agent": "bin/lightning-agent.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": ["lightning", "bitcoin", "ai", "agent", "nostr", "nwc", "payments"],
|
|
10
|
+
"author": "Jeletor",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"nostr-tools": "^2.0.0",
|
|
14
|
+
"ws": "^8.0.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createWallet, parseNwcUrl, decodeBolt11, NWCWallet } = require('./lib');
|
|
4
|
+
|
|
5
|
+
let passed = 0;
|
|
6
|
+
let failed = 0;
|
|
7
|
+
|
|
8
|
+
function assert(condition, message) {
|
|
9
|
+
if (condition) {
|
|
10
|
+
passed++;
|
|
11
|
+
console.log(` ✅ ${message}`);
|
|
12
|
+
} else {
|
|
13
|
+
failed++;
|
|
14
|
+
console.log(` ❌ ${message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertThrows(fn, message) {
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
failed++;
|
|
22
|
+
console.log(` ❌ ${message} (did not throw)`);
|
|
23
|
+
} catch (_) {
|
|
24
|
+
passed++;
|
|
25
|
+
console.log(` ✅ ${message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── NWC URL Parsing ───
|
|
30
|
+
console.log('\n📡 NWC URL Parsing');
|
|
31
|
+
|
|
32
|
+
const testNwcUrl = 'nostr+walletconnect://962852f75958e8920c8dfeffd59baa8a75bc7029143a7cea82875772863b0721?relay=wss://relay.getalby.com/v1&secret=7ed367a99f9bde637f4f960f398ab310aaadb0e10ff8273ca6f97e136146272f';
|
|
33
|
+
|
|
34
|
+
const parsed = parseNwcUrl(testNwcUrl);
|
|
35
|
+
assert(parsed.walletPubkey === '962852f75958e8920c8dfeffd59baa8a75bc7029143a7cea82875772863b0721', 'extracts walletPubkey');
|
|
36
|
+
assert(parsed.relay === 'wss://relay.getalby.com/v1', 'extracts relay URL');
|
|
37
|
+
assert(parsed.secret === '7ed367a99f9bde637f4f960f398ab310aaadb0e10ff8273ca6f97e136146272f', 'extracts secret');
|
|
38
|
+
|
|
39
|
+
assertThrows(() => parseNwcUrl('https://example.com'), 'rejects non-NWC URL');
|
|
40
|
+
assertThrows(() => parseNwcUrl(''), 'rejects empty string');
|
|
41
|
+
assertThrows(() => parseNwcUrl(null), 'rejects null');
|
|
42
|
+
assertThrows(
|
|
43
|
+
() => parseNwcUrl('nostr+walletconnect://abc?relay=wss://x.com&secret=abc'),
|
|
44
|
+
'rejects bad pubkey length'
|
|
45
|
+
);
|
|
46
|
+
assertThrows(
|
|
47
|
+
() => parseNwcUrl('nostr+walletconnect://962852f75958e8920c8dfeffd59baa8a75bc7029143a7cea82875772863b0721?secret=7ed367a99f9bde637f4f960f398ab310aaadb0e10ff8273ca6f97e136146272f'),
|
|
48
|
+
'rejects missing relay'
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// ─── Bolt11 Decoding ───
|
|
52
|
+
console.log('\n⚡ Bolt11 Amount Decoding');
|
|
53
|
+
|
|
54
|
+
// lnbc1u = 1 micro-BTC = 100 sats
|
|
55
|
+
const d1 = decodeBolt11('lnbc1u1ptest');
|
|
56
|
+
assert(d1.amountSats === 100, 'lnbc1u = 100 sats');
|
|
57
|
+
|
|
58
|
+
// lnbc210n = 210 nano-BTC = 21 sats
|
|
59
|
+
const d2 = decodeBolt11('lnbc210n1ptest');
|
|
60
|
+
assert(d2.amountSats === 21, 'lnbc210n = 21 sats');
|
|
61
|
+
|
|
62
|
+
// lnbc50u = 50 micro-BTC = 5000 sats
|
|
63
|
+
const d3 = decodeBolt11('lnbc50u1ptest');
|
|
64
|
+
assert(d3.amountSats === 5000, 'lnbc50u = 5000 sats');
|
|
65
|
+
|
|
66
|
+
// lnbc1m = 1 milli-BTC = 100000 sats
|
|
67
|
+
const d4 = decodeBolt11('lnbc1m1ptest');
|
|
68
|
+
assert(d4.amountSats === 100000, 'lnbc1m = 100000 sats');
|
|
69
|
+
|
|
70
|
+
// lnbc100n = 100 nano-BTC = 10 sats
|
|
71
|
+
const d5 = decodeBolt11('lnbc100n1ptest');
|
|
72
|
+
assert(d5.amountSats === 10, 'lnbc100n = 10 sats');
|
|
73
|
+
|
|
74
|
+
// lnbc2500u = 2500 micro-BTC = 250000 sats
|
|
75
|
+
const d6 = decodeBolt11('lnbc2500u1ptest');
|
|
76
|
+
assert(d6.amountSats === 250000, 'lnbc2500u = 250000 sats');
|
|
77
|
+
|
|
78
|
+
// lnbc10m = 10 milli-BTC = 1000000 sats
|
|
79
|
+
const d7 = decodeBolt11('lnbc10m1ptest');
|
|
80
|
+
assert(d7.amountSats === 1000000, 'lnbc10m = 1000000 sats');
|
|
81
|
+
|
|
82
|
+
// lnbc1500p = 1500 pico-BTC = 0.15 sats ≈ 0 sats (rounds)
|
|
83
|
+
const d8 = decodeBolt11('lnbc1500p1ptest');
|
|
84
|
+
assert(d8.amountSats === 0, 'lnbc1500p = 0 sats (sub-sat)');
|
|
85
|
+
|
|
86
|
+
// lnbc20n = 20 nano-BTC = 2 sats
|
|
87
|
+
const d9 = decodeBolt11('lnbc20n1ptest');
|
|
88
|
+
assert(d9.amountSats === 2, 'lnbc20n = 2 sats');
|
|
89
|
+
|
|
90
|
+
// Network detection
|
|
91
|
+
console.log('\n🌐 Network Detection');
|
|
92
|
+
assert(decodeBolt11('lnbc50u1ptest').network === 'mainnet', 'lnbc = mainnet');
|
|
93
|
+
assert(decodeBolt11('lntb50u1ptest').network === 'testnet', 'lntb = testnet');
|
|
94
|
+
|
|
95
|
+
// No amount (zero-amount invoice)
|
|
96
|
+
const d10 = decodeBolt11('lnbc1ptesttesttest');
|
|
97
|
+
assert(d10.amountSats === null, 'lnbc with no amount = null');
|
|
98
|
+
|
|
99
|
+
// Error cases
|
|
100
|
+
console.log('\n🚫 Bolt11 Error Cases');
|
|
101
|
+
assertThrows(() => decodeBolt11(''), 'rejects empty string');
|
|
102
|
+
assertThrows(() => decodeBolt11(null), 'rejects null');
|
|
103
|
+
assertThrows(() => decodeBolt11('notaninvoice'), 'rejects garbage');
|
|
104
|
+
|
|
105
|
+
// ─── createWallet interface ───
|
|
106
|
+
console.log('\n🔧 createWallet Interface');
|
|
107
|
+
|
|
108
|
+
const wallet = new NWCWallet(testNwcUrl);
|
|
109
|
+
assert(typeof wallet.getBalance === 'function', 'has getBalance()');
|
|
110
|
+
assert(typeof wallet.createInvoice === 'function', 'has createInvoice()');
|
|
111
|
+
assert(typeof wallet.payInvoice === 'function', 'has payInvoice()');
|
|
112
|
+
assert(typeof wallet.waitForPayment === 'function', 'has waitForPayment()');
|
|
113
|
+
assert(typeof wallet.decodeInvoice === 'function', 'has decodeInvoice()');
|
|
114
|
+
assert(typeof wallet.close === 'function', 'has close()');
|
|
115
|
+
|
|
116
|
+
// Test decodeInvoice works via wallet instance
|
|
117
|
+
const decoded = wallet.decodeInvoice('lnbc50u1ptest');
|
|
118
|
+
assert(decoded.amountSats === 5000, 'wallet.decodeInvoice works');
|
|
119
|
+
wallet.close();
|
|
120
|
+
|
|
121
|
+
// createWallet from env
|
|
122
|
+
console.log('\n🌍 createWallet from env');
|
|
123
|
+
process.env.NWC_URL = testNwcUrl;
|
|
124
|
+
const walletFromEnv = createWallet();
|
|
125
|
+
assert(walletFromEnv instanceof NWCWallet, 'createWallet() reads NWC_URL env');
|
|
126
|
+
walletFromEnv.close();
|
|
127
|
+
delete process.env.NWC_URL;
|
|
128
|
+
|
|
129
|
+
assertThrows(() => createWallet(), 'createWallet() throws without URL or env');
|
|
130
|
+
|
|
131
|
+
// ─── Summary ───
|
|
132
|
+
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
133
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
134
|
+
if (failed > 0) {
|
|
135
|
+
process.exit(1);
|
|
136
|
+
} else {
|
|
137
|
+
console.log('All tests passed! ✅');
|
|
138
|
+
}
|