lightning-wallet-mcp 1.4.0 → 1.5.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/README.md +71 -3
- package/dist/bolt11.d.ts +9 -0
- package/dist/bolt11.js +54 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +4 -4
- package/dist/lightning-faucet.d.ts +18 -1
- package/dist/lightning-faucet.js +102 -1
- package/dist/pre-payment-hook.d.ts +82 -0
- package/dist/pre-payment-hook.js +125 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
## What's New in v1.4
|
|
10
10
|
|
|
11
11
|
- **`update_operator` tool / `lw set-email`** - set your operator email from the MCP client or CLI; a verification link is emailed to you.
|
|
12
|
-
- **`claim_promo` tool / `lw claim-promo`** - claim the free-sats install promo directly from your agent. Requirements: verified email + operator account at least
|
|
12
|
+
- **`claim_promo` tool / `lw claim-promo`** - claim the free-sats install promo directly from your agent. Requirements: verified email + operator account at least 3 hours old.
|
|
13
13
|
- **`get_info` works before registration** - service info no longer requires an API key.
|
|
14
14
|
|
|
15
15
|
### Free 100 sats for new operators
|
|
16
16
|
|
|
17
17
|
1. `lw register --email you@example.com` (or the `register_operator` MCP tool with an email)
|
|
18
18
|
2. Click the verification link we email you
|
|
19
|
-
3. After your account is
|
|
19
|
+
3. After your account is 3 hours old: `lw claim-promo` (or the `claim_promo` MCP tool)
|
|
20
20
|
|
|
21
21
|
One bonus per operator, first 100 installs only, no deposit required.
|
|
22
22
|
|
|
@@ -174,7 +174,7 @@ Then ask Claude: *"Register a new Lightning Wallet operator account"*
|
|
|
174
174
|
| `set_operator_key` | Switch to operator credentials |
|
|
175
175
|
|
|
176
176
|
- `update_operator` - set operator email (sends verification link) and/or name
|
|
177
|
-
- `claim_promo` - claim the free-sats install promo (verified email +
|
|
177
|
+
- `claim_promo` - claim the free-sats install promo (verified email + 3h account)
|
|
178
178
|
|
|
179
179
|
### Agent Management
|
|
180
180
|
|
|
@@ -505,6 +505,74 @@ def verify_webhook(payload, signature, secret):
|
|
|
505
505
|
|
|
506
506
|
Check the `X-Webhook-Signature` header against the payload.
|
|
507
507
|
|
|
508
|
+
## Pre-Payment Policy Hook
|
|
509
|
+
|
|
510
|
+
An optional, vendor-neutral hook lets an external policy endpoint **allow or deny a payment before it executes**. It is off by default — when `PRE_PAYMENT_HOOK_URL` is unset, behaviour is exactly as before. When set, every outgoing payment (`pay_l402_api`, `pay_invoice`, `keysend`, `pay_lightning_address`) is checked against your endpoint first; a denial aborts the payment before any funds move.
|
|
511
|
+
|
|
512
|
+
This is useful for spending policies, approval workflows, compliance checks, or any external authorization layer. The hook protocol is generic, so any service implementing the request/response contract below can be wired in by configuration alone.
|
|
513
|
+
|
|
514
|
+
### Configuration
|
|
515
|
+
|
|
516
|
+
| Env var | Default | Description |
|
|
517
|
+
|---|---|---|
|
|
518
|
+
| `PRE_PAYMENT_HOOK_URL` | _(unset)_ | Policy endpoint to POST each payment proposal to. Unset disables the hook entirely. |
|
|
519
|
+
| `PRE_PAYMENT_HOOK_TIMEOUT_MS` | `3000` | Per-request timeout in milliseconds. |
|
|
520
|
+
| `PRE_PAYMENT_HOOK_FAIL_MODE` | `closed` | `closed` denies a payment if the hook errors or times out; `open` lets it proceed. Default is fail-closed. |
|
|
521
|
+
|
|
522
|
+
```jsonc
|
|
523
|
+
{
|
|
524
|
+
"mcpServers": {
|
|
525
|
+
"lightning-wallet": {
|
|
526
|
+
"command": "npx",
|
|
527
|
+
"args": ["lightning-wallet-mcp"],
|
|
528
|
+
"env": {
|
|
529
|
+
"LIGHTNING_WALLET_API_KEY": "your-api-key",
|
|
530
|
+
"PRE_PAYMENT_HOOK_URL": "https://your-policy-endpoint.example/hook"
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Hook request (POST from the client)
|
|
538
|
+
|
|
539
|
+
The proposal describes only the proposed payment — **it never includes your wallet API key**.
|
|
540
|
+
|
|
541
|
+
```json
|
|
542
|
+
{
|
|
543
|
+
"proposal_id": "f7e1…",
|
|
544
|
+
"agent_id": 42,
|
|
545
|
+
"protocol": "l402",
|
|
546
|
+
"destination_or_url": "https://api.example/paid-endpoint",
|
|
547
|
+
"amount_sats": null,
|
|
548
|
+
"max_payment_sats": 1000,
|
|
549
|
+
"method": "GET",
|
|
550
|
+
"ts": "2026-06-06T18:00:00.000Z"
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
`protocol` is one of `l402`, `x402`, `bolt11`, `keysend`, `lnaddress`. `amount_sats` is the exact amount when it is known at hook time: for `keysend` and `lnaddress` it is the requested amount, and for `bolt11` it is decoded locally from the invoice (no extra API call). For `l402`/`x402` it is `null` because the amount is set by the payment challenge at execution time — there the hook enforces `max_payment_sats` (the agent-authorised ceiling) up front, and the exact settled amount is available afterward via [webhooks](#webhooks). `max_payment_sats` is the agent-authorised ceiling when applicable.
|
|
555
|
+
|
|
556
|
+
**Exactly what leaves the wallet.** Only the eight fields above are sent to your hook endpoint: `proposal_id`, `agent_id`, `protocol`, `destination_or_url`, `amount_sats`, `max_payment_sats`, `method`, `ts`. The wallet API key and any other credentials are **never** included.
|
|
557
|
+
|
|
558
|
+
**Coverage.** The hook gates every agent-initiated spend: `pay_l402_api`, `pay_invoice`, `keysend`, `pay_lightning_address`, and Nostr zaps. Operator-scoped fund management (withdrawals, agent funding, agent-to-agent transfers) is intentionally **not** gated — those are operator actions, not agent spends.
|
|
559
|
+
|
|
560
|
+
### Hook response (your endpoint returns)
|
|
561
|
+
|
|
562
|
+
```json
|
|
563
|
+
{ "decision": "allow" }
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
```json
|
|
567
|
+
{ "decision": "deny", "reason": { "code": "over_limit", "message": "Exceeds per-transaction limit" } }
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
- `allow` → the payment proceeds.
|
|
571
|
+
- `deny` → the payment is aborted and the tool returns a `PolicyDenied` error surfacing `reason.message`.
|
|
572
|
+
- An optional `attestation` field (any JSON) is treated as opaque by the client — it is logged to stderr and otherwise ignored, so a policy service can return a signed decision for downstream auditing.
|
|
573
|
+
|
|
574
|
+
On a hook error, timeout, or unrecognized response, the `PRE_PAYMENT_HOOK_FAIL_MODE` applies (deny by default).
|
|
575
|
+
|
|
508
576
|
## Pricing
|
|
509
577
|
|
|
510
578
|
Lightning Faucet charges a 2% platform fee (min 1 sat) on outgoing payments:
|
package/dist/bolt11.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free BOLT11 amount decoder.
|
|
3
|
+
*
|
|
4
|
+
* Reads only the amount from the invoice's human-readable prefix (HRP). It does
|
|
5
|
+
* NOT bech32-decode the data part and makes no network call. Returns the amount
|
|
6
|
+
* in sats, or null when the invoice is amountless or cannot be confidently
|
|
7
|
+
* parsed — callers treat null as "amount unknown" and degrade gracefully.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bolt11AmountSats(invoice: string): number | null;
|
package/dist/bolt11.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal, dependency-free BOLT11 amount decoder.
|
|
4
|
+
*
|
|
5
|
+
* Reads only the amount from the invoice's human-readable prefix (HRP). It does
|
|
6
|
+
* NOT bech32-decode the data part and makes no network call. Returns the amount
|
|
7
|
+
* in sats, or null when the invoice is amountless or cannot be confidently
|
|
8
|
+
* parsed — callers treat null as "amount unknown" and degrade gracefully.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.bolt11AmountSats = bolt11AmountSats;
|
|
12
|
+
// msats per 1 unit of each multiplier (1 BTC = 1e11 msat).
|
|
13
|
+
const MSAT_PER_UNIT = {
|
|
14
|
+
m: 1e8, // milli (1e-3 BTC)
|
|
15
|
+
u: 1e5, // micro (1e-6 BTC)
|
|
16
|
+
n: 1e2, // nano (1e-9 BTC)
|
|
17
|
+
p: 1e-1, // pico (1e-12 BTC)
|
|
18
|
+
};
|
|
19
|
+
function bolt11AmountSats(invoice) {
|
|
20
|
+
try {
|
|
21
|
+
const lower = invoice.trim().toLowerCase();
|
|
22
|
+
if (!lower.startsWith('ln'))
|
|
23
|
+
return null;
|
|
24
|
+
// The bech32 data part never contains '1', so the LAST '1' is the separator
|
|
25
|
+
// between the human-readable prefix and the data.
|
|
26
|
+
const sep = lower.lastIndexOf('1');
|
|
27
|
+
if (sep < 1)
|
|
28
|
+
return null;
|
|
29
|
+
const hrp = lower.slice(0, sep);
|
|
30
|
+
// hrp = 'ln' + currency-prefix + <digits><multiplier?>. Amountless invoices
|
|
31
|
+
// (no digits) and unknown currency prefixes do not match → null.
|
|
32
|
+
const match = hrp.match(/^ln(?:bcrt|bc|tb|sb)(\d+)([munp])?$/);
|
|
33
|
+
if (!match)
|
|
34
|
+
return null;
|
|
35
|
+
const digits = match[1];
|
|
36
|
+
const multiplier = match[2];
|
|
37
|
+
let msat;
|
|
38
|
+
if (multiplier) {
|
|
39
|
+
const factor = MSAT_PER_UNIT[multiplier];
|
|
40
|
+
if (factor === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
msat = Number(digits) * factor;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
msat = Number(digits) * 1e11; // whole BTC
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isFinite(msat) || msat < 0)
|
|
48
|
+
return null;
|
|
49
|
+
return Math.round(msat / 1000);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -331,7 +331,7 @@ COMMANDS
|
|
|
331
331
|
transactions Recent transactions [--limit 10] [--offset 0]
|
|
332
332
|
set-email <email> Set operator email (verification link emailed)
|
|
333
333
|
claim-promo Claim the 100-sat install promo
|
|
334
|
-
(requires verified email +
|
|
334
|
+
(requires verified email + 3h account age)
|
|
335
335
|
help Show this help
|
|
336
336
|
|
|
337
337
|
WEBHOOKS
|
package/dist/index.js
CHANGED
|
@@ -104,7 +104,7 @@ const GetTransactionsSchema = zod_1.z.object({
|
|
|
104
104
|
// Operator management schemas
|
|
105
105
|
const RegisterOperatorSchema = zod_1.z.object({
|
|
106
106
|
name: zod_1.z.string().optional().describe('Name for the operator account'),
|
|
107
|
-
email: zod_1.z.string().email().optional().describe('
|
|
107
|
+
email: zod_1.z.string().email().optional().describe('Your email — register WITH it to claim the 100 free-sats install promo. A verification link is sent; once verified, claim_promo funds your wallet. Strongly recommended.'),
|
|
108
108
|
});
|
|
109
109
|
const GetDepositInvoiceSchema = zod_1.z.object({
|
|
110
110
|
amount_sats: zod_1.z.number().int().min(100).max(10_000_000).describe('Amount in satoshis to deposit'),
|
|
@@ -340,12 +340,12 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
|
340
340
|
// Operator management tools
|
|
341
341
|
{
|
|
342
342
|
name: 'register_operator',
|
|
343
|
-
description: 'Register a new operator account. Returns API key and recovery code. SAVE THESE - they cannot be retrieved later!',
|
|
343
|
+
description: 'Register a new operator account. Returns API key and recovery code. SAVE THESE - they cannot be retrieved later! Tip: pass an email to claim the 100 free-sats install promo.',
|
|
344
344
|
inputSchema: {
|
|
345
345
|
type: 'object',
|
|
346
346
|
properties: {
|
|
347
347
|
name: { type: 'string', description: 'Name for the operator account (optional)' },
|
|
348
|
-
email: { type: 'string', description: 'Email address —
|
|
348
|
+
email: { type: 'string', description: 'Email address — pass it here to claim the 100 free-sats install promo (a verification link is sent; once verified, and once the operator account is at least 3 hours old, call claim_promo to get funded). Also used for onboarding tips and important account notices.' },
|
|
349
349
|
},
|
|
350
350
|
required: [],
|
|
351
351
|
},
|
|
@@ -364,7 +364,7 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
|
364
364
|
},
|
|
365
365
|
{
|
|
366
366
|
name: 'claim_promo',
|
|
367
|
-
description: 'Claim the free-sats install promo (100 sats, first 100 installs). Requires a verified email (set one with update_operator, then click the emailed link) and an operator account at least
|
|
367
|
+
description: 'Claim the free-sats install promo (100 sats, first 100 installs). Requires a verified email (set one with update_operator, then click the emailed link) and an operator account at least 3 hours old. REQUIRES OPERATOR KEY.',
|
|
368
368
|
inputSchema: {
|
|
369
369
|
type: 'object',
|
|
370
370
|
properties: {
|
|
@@ -118,7 +118,24 @@ interface WhoamiResponse extends ApiResponse {
|
|
|
118
118
|
}
|
|
119
119
|
export declare class LightningFaucetClient {
|
|
120
120
|
private apiKey;
|
|
121
|
+
private agentIdCache;
|
|
121
122
|
constructor(apiKey: string);
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the current agent id for hook proposals (best-effort, cached).
|
|
125
|
+
* Returns null if it cannot be determined; a payment is never blocked solely
|
|
126
|
+
* because identity resolution failed — that decision is left to the policy hook.
|
|
127
|
+
*/
|
|
128
|
+
private resolveAgentId;
|
|
129
|
+
/**
|
|
130
|
+
* Run the optional pre-payment policy hook before executing a payment.
|
|
131
|
+
*
|
|
132
|
+
* No-op when `PRE_PAYMENT_HOOK_URL` is unset (behaviour is unchanged). When a
|
|
133
|
+
* hook is configured this POSTs a vendor-neutral proposal and throws
|
|
134
|
+
* `PolicyDenied` if the hook — or fail-closed handling of a hook error —
|
|
135
|
+
* denies the payment. Only the four explicit payment methods call this; it is
|
|
136
|
+
* invoked before any funds-moving request is sent.
|
|
137
|
+
*/
|
|
138
|
+
private enforcePrePaymentPolicy;
|
|
122
139
|
/**
|
|
123
140
|
* Make an API request to Lightning Faucet
|
|
124
141
|
*/
|
|
@@ -527,7 +544,7 @@ export declare class LightningFaucetClient {
|
|
|
527
544
|
}>;
|
|
528
545
|
/**
|
|
529
546
|
* Claim a promo bonus (default: the first_100_installs free-sats promo).
|
|
530
|
-
* Requires a verified email and an operator account at least
|
|
547
|
+
* Requires a verified email and an operator account at least 3 hours old.
|
|
531
548
|
*/
|
|
532
549
|
claimPromo(promoCode?: string): Promise<ApiResponse & {
|
|
533
550
|
promo?: string;
|
package/dist/lightning-faucet.js
CHANGED
|
@@ -8,15 +8,75 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.LightningFaucetClient = void 0;
|
|
9
9
|
exports.registerOperator = registerOperator;
|
|
10
10
|
exports.getPublicInfo = getPublicInfo;
|
|
11
|
+
const pre_payment_hook_js_1 = require("./pre-payment-hook.js");
|
|
12
|
+
const bolt11_js_1 = require("./bolt11.js");
|
|
11
13
|
const API_BASE_URL = process.env.LIGHTNING_WALLET_API_URL || 'https://lightningfaucet.com/ai-agents/api';
|
|
14
|
+
function safeStringify(value) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return '[unserializable]';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
12
22
|
class LightningFaucetClient {
|
|
13
23
|
apiKey;
|
|
24
|
+
agentIdCache;
|
|
14
25
|
constructor(apiKey) {
|
|
15
26
|
if (!apiKey) {
|
|
16
27
|
throw new Error('API key is required');
|
|
17
28
|
}
|
|
18
29
|
this.apiKey = apiKey;
|
|
19
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the current agent id for hook proposals (best-effort, cached).
|
|
33
|
+
* Returns null if it cannot be determined; a payment is never blocked solely
|
|
34
|
+
* because identity resolution failed — that decision is left to the policy hook.
|
|
35
|
+
*/
|
|
36
|
+
async resolveAgentId() {
|
|
37
|
+
if (this.agentIdCache !== undefined) {
|
|
38
|
+
return this.agentIdCache;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const who = await this.whoami();
|
|
42
|
+
this.agentIdCache = who.id || null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
this.agentIdCache = null;
|
|
46
|
+
}
|
|
47
|
+
return this.agentIdCache;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run the optional pre-payment policy hook before executing a payment.
|
|
51
|
+
*
|
|
52
|
+
* No-op when `PRE_PAYMENT_HOOK_URL` is unset (behaviour is unchanged). When a
|
|
53
|
+
* hook is configured this POSTs a vendor-neutral proposal and throws
|
|
54
|
+
* `PolicyDenied` if the hook — or fail-closed handling of a hook error —
|
|
55
|
+
* denies the payment. Only the four explicit payment methods call this; it is
|
|
56
|
+
* invoked before any funds-moving request is sent.
|
|
57
|
+
*/
|
|
58
|
+
async enforcePrePaymentPolicy(params) {
|
|
59
|
+
const config = (0, pre_payment_hook_js_1.getPrePaymentHookConfig)();
|
|
60
|
+
if (!config) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const agentId = await this.resolveAgentId();
|
|
64
|
+
const proposal = {
|
|
65
|
+
proposal_id: globalThis.crypto.randomUUID(),
|
|
66
|
+
agent_id: agentId,
|
|
67
|
+
protocol: params.protocol,
|
|
68
|
+
destination_or_url: params.destinationOrUrl,
|
|
69
|
+
amount_sats: params.amountSats,
|
|
70
|
+
max_payment_sats: params.maxPaymentSats,
|
|
71
|
+
method: params.method ?? null,
|
|
72
|
+
ts: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
const { attestation } = await (0, pre_payment_hook_js_1.runPrePaymentHook)(proposal, config);
|
|
75
|
+
if (attestation !== undefined) {
|
|
76
|
+
// Opaque to this client — logged on stderr to avoid corrupting MCP stdio.
|
|
77
|
+
console.error(`[pre-payment-hook] payment allowed with attestation: ${safeStringify(attestation)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
20
80
|
/**
|
|
21
81
|
* Make an API request to Lightning Faucet
|
|
22
82
|
*/
|
|
@@ -57,6 +117,15 @@ class LightningFaucetClient {
|
|
|
57
117
|
* Pay an L402-protected API endpoint
|
|
58
118
|
*/
|
|
59
119
|
async l402Pay(url, method = 'GET', body, maxPaymentSats = 1000) {
|
|
120
|
+
// L402 vs X402 is auto-detected server-side; the actual amount is set by the
|
|
121
|
+
// challenge, so only the agent-authorised ceiling is known at hook time.
|
|
122
|
+
await this.enforcePrePaymentPolicy({
|
|
123
|
+
protocol: 'l402',
|
|
124
|
+
destinationOrUrl: url,
|
|
125
|
+
amountSats: null,
|
|
126
|
+
maxPaymentSats,
|
|
127
|
+
method: method.toUpperCase(),
|
|
128
|
+
});
|
|
60
129
|
const requestData = {
|
|
61
130
|
url,
|
|
62
131
|
method: method.toUpperCase(),
|
|
@@ -93,6 +162,15 @@ class LightningFaucetClient {
|
|
|
93
162
|
* Pay a BOLT11 Lightning invoice
|
|
94
163
|
*/
|
|
95
164
|
async payInvoice(bolt11, maxFeeSats) {
|
|
165
|
+
// Decode the amount from the invoice locally (no extra API call) so the hook
|
|
166
|
+
// receives the exact amount; null only if the invoice is amountless/unparseable.
|
|
167
|
+
await this.enforcePrePaymentPolicy({
|
|
168
|
+
protocol: 'bolt11',
|
|
169
|
+
destinationOrUrl: bolt11,
|
|
170
|
+
amountSats: (0, bolt11_js_1.bolt11AmountSats)(bolt11),
|
|
171
|
+
maxPaymentSats: null,
|
|
172
|
+
method: null,
|
|
173
|
+
});
|
|
96
174
|
const data = {
|
|
97
175
|
invoice: bolt11,
|
|
98
176
|
};
|
|
@@ -573,6 +651,13 @@ class LightningFaucetClient {
|
|
|
573
651
|
* Pay to a Lightning address
|
|
574
652
|
*/
|
|
575
653
|
async payLightningAddress(address, amountSats, comment) {
|
|
654
|
+
await this.enforcePrePaymentPolicy({
|
|
655
|
+
protocol: 'lnaddress',
|
|
656
|
+
destinationOrUrl: address,
|
|
657
|
+
amountSats,
|
|
658
|
+
maxPaymentSats: amountSats,
|
|
659
|
+
method: null,
|
|
660
|
+
});
|
|
576
661
|
const data = {
|
|
577
662
|
address,
|
|
578
663
|
amount_sats: amountSats,
|
|
@@ -663,6 +748,13 @@ class LightningFaucetClient {
|
|
|
663
748
|
* Send keysend payment
|
|
664
749
|
*/
|
|
665
750
|
async keysend(destination, amountSats, message) {
|
|
751
|
+
await this.enforcePrePaymentPolicy({
|
|
752
|
+
protocol: 'keysend',
|
|
753
|
+
destinationOrUrl: destination,
|
|
754
|
+
amountSats,
|
|
755
|
+
maxPaymentSats: amountSats,
|
|
756
|
+
method: null,
|
|
757
|
+
});
|
|
666
758
|
const data = {
|
|
667
759
|
destination,
|
|
668
760
|
amount_sats: amountSats,
|
|
@@ -714,6 +806,15 @@ class LightningFaucetClient {
|
|
|
714
806
|
* Falls back to regular Lightning address payment if recipient doesn't support NIP-57
|
|
715
807
|
*/
|
|
716
808
|
async nostrZap(address, amountSats, recipientPubkey, content, eventId, relays) {
|
|
809
|
+
// A Nostr zap settles as a Lightning-address payment, so it is an agent spend
|
|
810
|
+
// and must pass the policy hook like any other.
|
|
811
|
+
await this.enforcePrePaymentPolicy({
|
|
812
|
+
protocol: 'lnaddress',
|
|
813
|
+
destinationOrUrl: address,
|
|
814
|
+
amountSats,
|
|
815
|
+
maxPaymentSats: amountSats,
|
|
816
|
+
method: null,
|
|
817
|
+
});
|
|
717
818
|
const data = {
|
|
718
819
|
address,
|
|
719
820
|
amount_sats: amountSats,
|
|
@@ -789,7 +890,7 @@ class LightningFaucetClient {
|
|
|
789
890
|
}
|
|
790
891
|
/**
|
|
791
892
|
* Claim a promo bonus (default: the first_100_installs free-sats promo).
|
|
792
|
-
* Requires a verified email and an operator account at least
|
|
893
|
+
* Requires a verified email and an operator account at least 3 hours old.
|
|
793
894
|
*/
|
|
794
895
|
async claimPromo(promoCode) {
|
|
795
896
|
const params = {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-Payment Policy Hook
|
|
3
|
+
*
|
|
4
|
+
* An optional, vendor-neutral hook that lets an external policy endpoint allow
|
|
5
|
+
* or deny a payment *before* it executes. When `PRE_PAYMENT_HOOK_URL` is unset
|
|
6
|
+
* the hook is inert and payment behaviour is identical to having no hook at all.
|
|
7
|
+
*
|
|
8
|
+
* This module contains no vendor-specific logic. It POSTs a generic payment
|
|
9
|
+
* proposal and interprets a generic decision, so any policy service that speaks
|
|
10
|
+
* the documented request/response contract can be wired in by configuration
|
|
11
|
+
* alone. The proposal deliberately carries NO wallet credentials.
|
|
12
|
+
*/
|
|
13
|
+
export type PaymentProtocol = 'l402' | 'x402' | 'bolt11' | 'keysend' | 'lnaddress';
|
|
14
|
+
export type HookFailMode = 'closed' | 'open';
|
|
15
|
+
export interface PrePaymentProposal {
|
|
16
|
+
/** Unique id for this proposal (one per payment attempt). */
|
|
17
|
+
proposal_id: string;
|
|
18
|
+
/** The paying agent's id, when resolvable; null otherwise. */
|
|
19
|
+
agent_id: number | null;
|
|
20
|
+
/** Payment protocol being attempted. */
|
|
21
|
+
protocol: PaymentProtocol;
|
|
22
|
+
/** Destination address, node pubkey, lightning address, invoice, or target URL. */
|
|
23
|
+
destination_or_url: string;
|
|
24
|
+
/** Exact amount when known at hook time; null when the protocol determines it later. */
|
|
25
|
+
amount_sats: number | null;
|
|
26
|
+
/** Upper bound the agent authorised, when applicable; null otherwise. */
|
|
27
|
+
max_payment_sats: number | null;
|
|
28
|
+
/** HTTP method for L402/X402 URL payments; null for direct Lightning payments. */
|
|
29
|
+
method: string | null;
|
|
30
|
+
/** ISO-8601 timestamp of the proposal. */
|
|
31
|
+
ts: string;
|
|
32
|
+
}
|
|
33
|
+
export interface HookDecisionReason {
|
|
34
|
+
code?: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface HookDecision {
|
|
38
|
+
decision: 'allow' | 'deny';
|
|
39
|
+
reason?: HookDecisionReason;
|
|
40
|
+
/** Opaque to this client; forwarded/logged only. */
|
|
41
|
+
attestation?: unknown;
|
|
42
|
+
}
|
|
43
|
+
export interface PrePaymentHookConfig {
|
|
44
|
+
url: string;
|
|
45
|
+
timeoutMs: number;
|
|
46
|
+
failMode: HookFailMode;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read hook configuration from the environment. Returns null when no hook URL is
|
|
50
|
+
* configured, in which case callers MUST behave exactly as before.
|
|
51
|
+
*
|
|
52
|
+
* - `PRE_PAYMENT_HOOK_URL` — when set, enables the hook.
|
|
53
|
+
* - `PRE_PAYMENT_HOOK_TIMEOUT_MS` — request timeout; defaults to 3000.
|
|
54
|
+
* - `PRE_PAYMENT_HOOK_FAIL_MODE` — `closed` (default) denies on hook error/timeout;
|
|
55
|
+
* `open` proceeds on hook error/timeout.
|
|
56
|
+
*/
|
|
57
|
+
export declare function getPrePaymentHookConfig(env?: Record<string, string | undefined>): PrePaymentHookConfig | null;
|
|
58
|
+
/**
|
|
59
|
+
* Error thrown when a payment is denied — either explicitly by the hook
|
|
60
|
+
* (`decision: "deny"`) or implicitly by a hook error under fail-closed mode.
|
|
61
|
+
*/
|
|
62
|
+
export declare class PolicyDenied extends Error {
|
|
63
|
+
readonly code: string;
|
|
64
|
+
readonly reason?: HookDecisionReason;
|
|
65
|
+
constructor(message: string, code?: string, reason?: HookDecisionReason);
|
|
66
|
+
}
|
|
67
|
+
export interface RunHookDeps {
|
|
68
|
+
/** Injectable fetch, primarily for tests. Defaults to the global fetch. */
|
|
69
|
+
fetchImpl?: typeof fetch;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run the pre-payment hook for a proposal.
|
|
73
|
+
*
|
|
74
|
+
* - decision `allow`: resolves with the (opaque) attestation, if any.
|
|
75
|
+
* - decision `deny`: throws {@link PolicyDenied} carrying the structured reason.
|
|
76
|
+
* - transport error / timeout / non-2xx / malformed response: applies failMode —
|
|
77
|
+
* `closed` throws {@link PolicyDenied}; `open` resolves (payment proceeds) after
|
|
78
|
+
* logging the failure to stderr.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runPrePaymentHook(proposal: PrePaymentProposal, config: PrePaymentHookConfig, deps?: RunHookDeps): Promise<{
|
|
81
|
+
attestation?: unknown;
|
|
82
|
+
}>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pre-Payment Policy Hook
|
|
4
|
+
*
|
|
5
|
+
* An optional, vendor-neutral hook that lets an external policy endpoint allow
|
|
6
|
+
* or deny a payment *before* it executes. When `PRE_PAYMENT_HOOK_URL` is unset
|
|
7
|
+
* the hook is inert and payment behaviour is identical to having no hook at all.
|
|
8
|
+
*
|
|
9
|
+
* This module contains no vendor-specific logic. It POSTs a generic payment
|
|
10
|
+
* proposal and interprets a generic decision, so any policy service that speaks
|
|
11
|
+
* the documented request/response contract can be wired in by configuration
|
|
12
|
+
* alone. The proposal deliberately carries NO wallet credentials.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.PolicyDenied = void 0;
|
|
16
|
+
exports.getPrePaymentHookConfig = getPrePaymentHookConfig;
|
|
17
|
+
exports.runPrePaymentHook = runPrePaymentHook;
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
19
|
+
/**
|
|
20
|
+
* Read hook configuration from the environment. Returns null when no hook URL is
|
|
21
|
+
* configured, in which case callers MUST behave exactly as before.
|
|
22
|
+
*
|
|
23
|
+
* - `PRE_PAYMENT_HOOK_URL` — when set, enables the hook.
|
|
24
|
+
* - `PRE_PAYMENT_HOOK_TIMEOUT_MS` — request timeout; defaults to 3000.
|
|
25
|
+
* - `PRE_PAYMENT_HOOK_FAIL_MODE` — `closed` (default) denies on hook error/timeout;
|
|
26
|
+
* `open` proceeds on hook error/timeout.
|
|
27
|
+
*/
|
|
28
|
+
function getPrePaymentHookConfig(env = process.env) {
|
|
29
|
+
const url = env.PRE_PAYMENT_HOOK_URL?.trim();
|
|
30
|
+
if (!url) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const timeoutRaw = env.PRE_PAYMENT_HOOK_TIMEOUT_MS?.trim();
|
|
34
|
+
const parsedTimeout = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : NaN;
|
|
35
|
+
const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS;
|
|
36
|
+
// Default fail-closed: when a hook is configured, a hook error denies the payment.
|
|
37
|
+
const failMode = env.PRE_PAYMENT_HOOK_FAIL_MODE?.trim().toLowerCase() === 'open' ? 'open' : 'closed';
|
|
38
|
+
return { url, timeoutMs, failMode };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Error thrown when a payment is denied — either explicitly by the hook
|
|
42
|
+
* (`decision: "deny"`) or implicitly by a hook error under fail-closed mode.
|
|
43
|
+
*/
|
|
44
|
+
class PolicyDenied extends Error {
|
|
45
|
+
code;
|
|
46
|
+
reason;
|
|
47
|
+
constructor(message, code = 'policy_denied', reason) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'PolicyDenied';
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.reason = reason;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.PolicyDenied = PolicyDenied;
|
|
55
|
+
/**
|
|
56
|
+
* Run the pre-payment hook for a proposal.
|
|
57
|
+
*
|
|
58
|
+
* - decision `allow`: resolves with the (opaque) attestation, if any.
|
|
59
|
+
* - decision `deny`: throws {@link PolicyDenied} carrying the structured reason.
|
|
60
|
+
* - transport error / timeout / non-2xx / malformed response: applies failMode —
|
|
61
|
+
* `closed` throws {@link PolicyDenied}; `open` resolves (payment proceeds) after
|
|
62
|
+
* logging the failure to stderr.
|
|
63
|
+
*/
|
|
64
|
+
async function runPrePaymentHook(proposal, config, deps = {}) {
|
|
65
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
68
|
+
// The timer lives in a try/finally with NO catch: a fail-closed PolicyDenied
|
|
69
|
+
// (from applyFailMode or the deny branch) propagates straight out, clearing
|
|
70
|
+
// the timer, and is never caught and re-wrapped. Only the fetch and the body
|
|
71
|
+
// read are wrapped in their own try/catch, so their failures map to fail-mode
|
|
72
|
+
// exactly once with the right detail.
|
|
73
|
+
try {
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = await doFetch(config.url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify(proposal),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return applyFailMode(config, transportDetail(err, config));
|
|
85
|
+
}
|
|
86
|
+
// Non-2xx is handled OUTSIDE the fetch try/catch so the status detail is
|
|
87
|
+
// preserved and fail-mode runs once.
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
return applyFailMode(config, `policy hook returned HTTP ${response.status}`);
|
|
90
|
+
}
|
|
91
|
+
let decision;
|
|
92
|
+
try {
|
|
93
|
+
decision = (await response.json());
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return applyFailMode(config, transportDetail(err, config, 'policy hook returned an unparseable body'));
|
|
97
|
+
}
|
|
98
|
+
if (decision?.decision === 'allow') {
|
|
99
|
+
return { attestation: decision.attestation };
|
|
100
|
+
}
|
|
101
|
+
if (decision?.decision === 'deny') {
|
|
102
|
+
const reason = decision.reason;
|
|
103
|
+
const detail = reason?.message || 'payment denied by policy';
|
|
104
|
+
throw new PolicyDenied(`Payment blocked by pre-payment policy hook: ${detail}`, reason?.code || 'policy_denied', reason);
|
|
105
|
+
}
|
|
106
|
+
// Malformed/unknown decision — treat as a hook error and apply the fail mode.
|
|
107
|
+
return applyFailMode(config, 'policy hook returned an unrecognized decision');
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function transportDetail(err, config, fallback = 'policy hook request failed') {
|
|
114
|
+
return err instanceof Error && err.name === 'AbortError'
|
|
115
|
+
? `policy hook timed out after ${config.timeoutMs}ms`
|
|
116
|
+
: fallback;
|
|
117
|
+
}
|
|
118
|
+
function applyFailMode(config, detail) {
|
|
119
|
+
if (config.failMode === 'open') {
|
|
120
|
+
// Fail-open: allow the payment, but record the hook failure on stderr.
|
|
121
|
+
console.error(`[pre-payment-hook] fail-open: proceeding despite ${detail}`);
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
throw new PolicyDenied(`Payment blocked by pre-payment policy hook (fail-closed): ${detail}`, 'policy_hook_unavailable');
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightning-wallet-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"mcpName": "io.github.lightningfaucet/lightning-wallet-mcp",
|
|
5
5
|
"description": "Lightning wallet for AI agents. MCP server + CLI. Works with Claude Code, OpenClaw, Cursor, and any agent framework.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"build": "tsc",
|
|
14
14
|
"watch": "tsc --watch",
|
|
15
15
|
"start": "node dist/index.js",
|
|
16
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
16
17
|
"prepublishOnly": "npm run build"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [
|