lightning-wallet-mcp 1.4.1 → 1.5.1
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 +68 -0
- package/dist/bolt11.d.ts +9 -0
- package/dist/bolt11.js +54 -0
- package/dist/index.js +50 -50
- package/dist/lightning-faucet.d.ts +17 -0
- package/dist/lightning-faucet.js +101 -0
- 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
|
@@ -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/index.js
CHANGED
|
@@ -85,7 +85,7 @@ const PayL402ApiSchema = zod_1.z.object({
|
|
|
85
85
|
.describe('Maximum amount in satoshis to pay for this request'),
|
|
86
86
|
});
|
|
87
87
|
const PayInvoiceSchema = zod_1.z.object({
|
|
88
|
-
bolt11: zod_1.z.string().regex(/^ln(bc|tb|bcrt)1[a-z0-9]+$/i, 'Invalid BOLT11 invoice format')
|
|
88
|
+
bolt11: zod_1.z.string().regex(/^ln(bc|tb|bcrt)([0-9]+[munp]?)?1[a-z0-9]+$/i, 'Invalid BOLT11 invoice format')
|
|
89
89
|
.describe('BOLT11 invoice string to pay (starts with lnbc...)'),
|
|
90
90
|
max_fee_sats: zod_1.z.number().min(0).optional()
|
|
91
91
|
.describe('Maximum routing fee in satoshis (default: 10% of invoice amount)'),
|
|
@@ -98,7 +98,7 @@ const GetInvoiceStatusSchema = zod_1.z.object({
|
|
|
98
98
|
payment_hash: zod_1.z.string().describe('Payment hash of the invoice to check'),
|
|
99
99
|
});
|
|
100
100
|
const GetTransactionsSchema = zod_1.z.object({
|
|
101
|
-
limit: zod_1.z.number().min(1).max(
|
|
101
|
+
limit: zod_1.z.number().min(1).max(100).default(50).describe('Max transactions to return'),
|
|
102
102
|
offset: zod_1.z.number().min(0).default(0).describe('Number to skip for pagination'),
|
|
103
103
|
});
|
|
104
104
|
// Operator management schemas
|
|
@@ -173,13 +173,13 @@ const RotateApiKeySchema = zod_1.z.object({
|
|
|
173
173
|
// Tier 2 schemas
|
|
174
174
|
const GetInfoSchema = zod_1.z.object({});
|
|
175
175
|
const DecodeInvoiceSchema = zod_1.z.object({
|
|
176
|
-
bolt11: zod_1.z.string().regex(/^ln(bc|tb|bcrt)1[a-z0-9]+$/i, 'Invalid BOLT11 invoice format')
|
|
176
|
+
bolt11: zod_1.z.string().regex(/^ln(bc|tb|bcrt)([0-9]+[munp]?)?1[a-z0-9]+$/i, 'Invalid BOLT11 invoice format')
|
|
177
177
|
.describe('BOLT11 invoice string to decode'),
|
|
178
178
|
});
|
|
179
179
|
const GetRateLimitsSchema = zod_1.z.object({});
|
|
180
180
|
// Tier 3 schemas
|
|
181
181
|
const WithdrawSchema = zod_1.z.object({
|
|
182
|
-
invoice: zod_1.z.string().regex(/^ln(bc|tb|bcrt)1[a-z0-9]+$/i, 'Invalid BOLT11 invoice format')
|
|
182
|
+
invoice: zod_1.z.string().regex(/^ln(bc|tb|bcrt)([0-9]+[munp]?)?1[a-z0-9]+$/i, 'Invalid BOLT11 invoice format')
|
|
183
183
|
.describe('BOLT11 invoice to pay out to'),
|
|
184
184
|
});
|
|
185
185
|
const SweepAgentSchema = zod_1.z.object({
|
|
@@ -822,7 +822,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
822
822
|
try {
|
|
823
823
|
switch (name) {
|
|
824
824
|
case 'check_balance': {
|
|
825
|
-
CheckBalanceSchema.parse(args);
|
|
825
|
+
CheckBalanceSchema.parse(args ?? {});
|
|
826
826
|
const result = await session.requireClient().checkBalance();
|
|
827
827
|
return {
|
|
828
828
|
content: [
|
|
@@ -838,7 +838,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
838
838
|
};
|
|
839
839
|
}
|
|
840
840
|
case 'pay_l402_api': {
|
|
841
|
-
const parsed = PayL402ApiSchema.parse(args);
|
|
841
|
+
const parsed = PayL402ApiSchema.parse(args ?? {});
|
|
842
842
|
const result = await session.requireClient().l402Pay(parsed.url, parsed.method, parsed.body, parsed.max_payment_sats);
|
|
843
843
|
// Determine if the target returned a success response
|
|
844
844
|
const targetSuccess = result.statusCode >= 200 && result.statusCode < 300;
|
|
@@ -870,7 +870,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
870
870
|
};
|
|
871
871
|
}
|
|
872
872
|
case 'pay_invoice': {
|
|
873
|
-
const parsed = PayInvoiceSchema.parse(args);
|
|
873
|
+
const parsed = PayInvoiceSchema.parse(args ?? {});
|
|
874
874
|
const result = await session.requireClient().payInvoice(parsed.bolt11, parsed.max_fee_sats);
|
|
875
875
|
return {
|
|
876
876
|
content: [
|
|
@@ -892,7 +892,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
892
892
|
};
|
|
893
893
|
}
|
|
894
894
|
case 'create_invoice': {
|
|
895
|
-
const parsed = CreateInvoiceSchema.parse(args);
|
|
895
|
+
const parsed = CreateInvoiceSchema.parse(args ?? {});
|
|
896
896
|
const result = await session.requireClient().createInvoice(parsed.amount_sats, parsed.memo);
|
|
897
897
|
const invoiceResponse = {
|
|
898
898
|
success: true,
|
|
@@ -914,7 +914,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
914
914
|
};
|
|
915
915
|
}
|
|
916
916
|
case 'get_invoice_status': {
|
|
917
|
-
const parsed = GetInvoiceStatusSchema.parse(args);
|
|
917
|
+
const parsed = GetInvoiceStatusSchema.parse(args ?? {});
|
|
918
918
|
const result = await session.requireClient().getInvoiceStatus(parsed.payment_hash);
|
|
919
919
|
return {
|
|
920
920
|
content: [
|
|
@@ -934,7 +934,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
934
934
|
};
|
|
935
935
|
}
|
|
936
936
|
case 'get_transactions': {
|
|
937
|
-
const parsed = GetTransactionsSchema.parse(args);
|
|
937
|
+
const parsed = GetTransactionsSchema.parse(args ?? {});
|
|
938
938
|
const result = await session.requireClient().getTransactions(parsed.limit, parsed.offset);
|
|
939
939
|
return {
|
|
940
940
|
content: [
|
|
@@ -952,7 +952,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
952
952
|
}
|
|
953
953
|
// Operator management tool handlers
|
|
954
954
|
case 'register_operator': {
|
|
955
|
-
const parsed = RegisterOperatorSchema.parse(args);
|
|
955
|
+
const parsed = RegisterOperatorSchema.parse(args ?? {});
|
|
956
956
|
const result = await (0, lightning_faucet_js_1.registerOperator)(parsed.name, parsed.email);
|
|
957
957
|
// Auto-set credentials so subsequent requests use the new operator key
|
|
958
958
|
session.setClient(new lightning_faucet_js_1.LightningFaucetClient(result.apiKey));
|
|
@@ -974,7 +974,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
974
974
|
};
|
|
975
975
|
}
|
|
976
976
|
case 'update_operator': {
|
|
977
|
-
const parsed = UpdateOperatorSchema.parse(args);
|
|
977
|
+
const parsed = UpdateOperatorSchema.parse(args ?? {});
|
|
978
978
|
if (parsed.email === undefined && parsed.name === undefined) {
|
|
979
979
|
throw new Error('Provide email and/or name to update.');
|
|
980
980
|
}
|
|
@@ -994,7 +994,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
994
994
|
};
|
|
995
995
|
}
|
|
996
996
|
case 'claim_promo': {
|
|
997
|
-
const parsed = ClaimPromoSchema.parse(args);
|
|
997
|
+
const parsed = ClaimPromoSchema.parse(args ?? {});
|
|
998
998
|
const result = await session.requireClient().claimPromo(parsed.promo_code);
|
|
999
999
|
return {
|
|
1000
1000
|
content: [
|
|
@@ -1006,7 +1006,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1006
1006
|
};
|
|
1007
1007
|
}
|
|
1008
1008
|
case 'get_deposit_invoice': {
|
|
1009
|
-
const parsed = GetDepositInvoiceSchema.parse(args);
|
|
1009
|
+
const parsed = GetDepositInvoiceSchema.parse(args ?? {});
|
|
1010
1010
|
const result = await session.requireClient().getDepositInvoice(parsed.amount_sats);
|
|
1011
1011
|
return {
|
|
1012
1012
|
content: [
|
|
@@ -1026,7 +1026,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1026
1026
|
};
|
|
1027
1027
|
}
|
|
1028
1028
|
case 'create_agent': {
|
|
1029
|
-
const parsed = CreateAgentSchema.parse(args);
|
|
1029
|
+
const parsed = CreateAgentSchema.parse(args ?? {});
|
|
1030
1030
|
const result = await session.requireClient().createAgent(parsed.name, parsed.description, parsed.budget_limit_sats);
|
|
1031
1031
|
return {
|
|
1032
1032
|
content: [
|
|
@@ -1044,7 +1044,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1044
1044
|
};
|
|
1045
1045
|
}
|
|
1046
1046
|
case 'fund_agent': {
|
|
1047
|
-
const parsed = FundAgentSchema.parse(args);
|
|
1047
|
+
const parsed = FundAgentSchema.parse(args ?? {});
|
|
1048
1048
|
const result = await session.requireClient().fundAgent(parsed.agent_id, parsed.amount_sats);
|
|
1049
1049
|
return {
|
|
1050
1050
|
content: [
|
|
@@ -1062,7 +1062,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1062
1062
|
};
|
|
1063
1063
|
}
|
|
1064
1064
|
case 'list_agents': {
|
|
1065
|
-
ListAgentsSchema.parse(args);
|
|
1065
|
+
ListAgentsSchema.parse(args ?? {});
|
|
1066
1066
|
const result = await session.requireClient().listAgents();
|
|
1067
1067
|
return {
|
|
1068
1068
|
content: [
|
|
@@ -1078,7 +1078,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1078
1078
|
};
|
|
1079
1079
|
}
|
|
1080
1080
|
case 'set_operator_key': {
|
|
1081
|
-
const parsed = SetOperatorKeySchema.parse(args);
|
|
1081
|
+
const parsed = SetOperatorKeySchema.parse(args ?? {});
|
|
1082
1082
|
session.setClient(new lightning_faucet_js_1.LightningFaucetClient(parsed.api_key));
|
|
1083
1083
|
return {
|
|
1084
1084
|
content: [
|
|
@@ -1093,7 +1093,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1093
1093
|
};
|
|
1094
1094
|
}
|
|
1095
1095
|
case 'set_agent_credentials': {
|
|
1096
|
-
const parsed = SetAgentCredentialsSchema.parse(args);
|
|
1096
|
+
const parsed = SetAgentCredentialsSchema.parse(args ?? {});
|
|
1097
1097
|
session.setClient(new lightning_faucet_js_1.LightningFaucetClient(parsed.api_key));
|
|
1098
1098
|
return {
|
|
1099
1099
|
content: [
|
|
@@ -1108,7 +1108,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1108
1108
|
};
|
|
1109
1109
|
}
|
|
1110
1110
|
case 'whoami': {
|
|
1111
|
-
WhoamiSchema.parse(args);
|
|
1111
|
+
WhoamiSchema.parse(args ?? {});
|
|
1112
1112
|
const result = await session.requireClient().whoami();
|
|
1113
1113
|
const response = {
|
|
1114
1114
|
success: true,
|
|
@@ -1137,7 +1137,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1137
1137
|
// TIER 1: Webhook Management
|
|
1138
1138
|
// ==========================================
|
|
1139
1139
|
case 'register_webhook': {
|
|
1140
|
-
const parsed = RegisterWebhookSchema.parse(args);
|
|
1140
|
+
const parsed = RegisterWebhookSchema.parse(args ?? {});
|
|
1141
1141
|
const result = await session.requireClient().registerWebhook(parsed.url, parsed.events);
|
|
1142
1142
|
return {
|
|
1143
1143
|
content: [
|
|
@@ -1156,7 +1156,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1156
1156
|
};
|
|
1157
1157
|
}
|
|
1158
1158
|
case 'list_webhooks': {
|
|
1159
|
-
ListWebhooksSchema.parse(args);
|
|
1159
|
+
ListWebhooksSchema.parse(args ?? {});
|
|
1160
1160
|
const result = await session.requireClient().listWebhooks();
|
|
1161
1161
|
return {
|
|
1162
1162
|
content: [
|
|
@@ -1172,7 +1172,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1172
1172
|
};
|
|
1173
1173
|
}
|
|
1174
1174
|
case 'delete_webhook': {
|
|
1175
|
-
const parsed = DeleteWebhookSchema.parse(args);
|
|
1175
|
+
const parsed = DeleteWebhookSchema.parse(args ?? {});
|
|
1176
1176
|
const result = await session.requireClient().deleteWebhook(parsed.webhook_id);
|
|
1177
1177
|
return {
|
|
1178
1178
|
content: [
|
|
@@ -1187,7 +1187,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1187
1187
|
};
|
|
1188
1188
|
}
|
|
1189
1189
|
case 'test_webhook': {
|
|
1190
|
-
const parsed = TestWebhookSchema.parse(args);
|
|
1190
|
+
const parsed = TestWebhookSchema.parse(args ?? {});
|
|
1191
1191
|
const result = await session.requireClient().testWebhook(parsed.webhook_id);
|
|
1192
1192
|
return {
|
|
1193
1193
|
content: [
|
|
@@ -1206,7 +1206,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1206
1206
|
// TIER 1: Budget Management
|
|
1207
1207
|
// ==========================================
|
|
1208
1208
|
case 'get_budget_status': {
|
|
1209
|
-
const parsed = GetBudgetStatusSchema.parse(args);
|
|
1209
|
+
const parsed = GetBudgetStatusSchema.parse(args ?? {});
|
|
1210
1210
|
const result = await session.requireClient().getBudgetStatus(parsed.agent_id);
|
|
1211
1211
|
return {
|
|
1212
1212
|
content: [
|
|
@@ -1225,7 +1225,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1225
1225
|
};
|
|
1226
1226
|
}
|
|
1227
1227
|
case 'set_budget': {
|
|
1228
|
-
const parsed = SetBudgetSchema.parse(args);
|
|
1228
|
+
const parsed = SetBudgetSchema.parse(args ?? {});
|
|
1229
1229
|
const result = await session.requireClient().setBudget(parsed.agent_id, parsed.budget_limit_sats);
|
|
1230
1230
|
return {
|
|
1231
1231
|
content: [
|
|
@@ -1245,7 +1245,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1245
1245
|
// TIER 1: Agent Lifecycle
|
|
1246
1246
|
// ==========================================
|
|
1247
1247
|
case 'deactivate_agent': {
|
|
1248
|
-
const parsed = DeactivateAgentSchema.parse(args);
|
|
1248
|
+
const parsed = DeactivateAgentSchema.parse(args ?? {});
|
|
1249
1249
|
const result = await session.requireClient().deactivateAgent(parsed.agent_id);
|
|
1250
1250
|
return {
|
|
1251
1251
|
content: [
|
|
@@ -1261,7 +1261,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1261
1261
|
};
|
|
1262
1262
|
}
|
|
1263
1263
|
case 'reactivate_agent': {
|
|
1264
|
-
const parsed = ReactivateAgentSchema.parse(args);
|
|
1264
|
+
const parsed = ReactivateAgentSchema.parse(args ?? {});
|
|
1265
1265
|
const result = await session.requireClient().reactivateAgent(parsed.agent_id);
|
|
1266
1266
|
return {
|
|
1267
1267
|
content: [
|
|
@@ -1280,7 +1280,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1280
1280
|
// TIER 1: Recovery & Key Rotation
|
|
1281
1281
|
// ==========================================
|
|
1282
1282
|
case 'recover_account': {
|
|
1283
|
-
const parsed = RecoverAccountSchema.parse(args);
|
|
1283
|
+
const parsed = RecoverAccountSchema.parse(args ?? {});
|
|
1284
1284
|
// Recovery doesn't need an existing API key — recoverAccount() uses its own fetch
|
|
1285
1285
|
const tempClient = session.getClient() || new lightning_faucet_js_1.LightningFaucetClient('recovery-placeholder');
|
|
1286
1286
|
const result = await tempClient.recoverAccount(parsed.recovery_code);
|
|
@@ -1302,7 +1302,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1302
1302
|
};
|
|
1303
1303
|
}
|
|
1304
1304
|
case 'rotate_api_key': {
|
|
1305
|
-
const parsed = RotateApiKeySchema.parse(args);
|
|
1305
|
+
const parsed = RotateApiKeySchema.parse(args ?? {});
|
|
1306
1306
|
const result = await session.requireClient().rotateApiKey(parsed.agent_id);
|
|
1307
1307
|
// Auto-switch to the new key
|
|
1308
1308
|
session.setClient(new lightning_faucet_js_1.LightningFaucetClient(result.apiKey));
|
|
@@ -1324,7 +1324,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1324
1324
|
// TIER 2: Service Info & Invoice Decoding
|
|
1325
1325
|
// ==========================================
|
|
1326
1326
|
case 'get_info': {
|
|
1327
|
-
GetInfoSchema.parse(args);
|
|
1327
|
+
GetInfoSchema.parse(args ?? {});
|
|
1328
1328
|
// Service info is public - fall back to an unauthenticated request so
|
|
1329
1329
|
// first-run users can check the service before registering.
|
|
1330
1330
|
let result;
|
|
@@ -1352,7 +1352,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1352
1352
|
};
|
|
1353
1353
|
}
|
|
1354
1354
|
case 'decode_invoice': {
|
|
1355
|
-
const parsed = DecodeInvoiceSchema.parse(args);
|
|
1355
|
+
const parsed = DecodeInvoiceSchema.parse(args ?? {});
|
|
1356
1356
|
const result = await session.requireClient().decodeInvoice(parsed.bolt11);
|
|
1357
1357
|
return {
|
|
1358
1358
|
content: [
|
|
@@ -1373,7 +1373,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1373
1373
|
};
|
|
1374
1374
|
}
|
|
1375
1375
|
case 'get_rate_limits': {
|
|
1376
|
-
GetRateLimitsSchema.parse(args);
|
|
1376
|
+
GetRateLimitsSchema.parse(args ?? {});
|
|
1377
1377
|
const result = await session.requireClient().getRateLimits();
|
|
1378
1378
|
return {
|
|
1379
1379
|
content: [
|
|
@@ -1393,7 +1393,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1393
1393
|
// TIER 3: Withdrawals & Advanced Payments
|
|
1394
1394
|
// ==========================================
|
|
1395
1395
|
case 'withdraw': {
|
|
1396
|
-
const parsed = WithdrawSchema.parse(args);
|
|
1396
|
+
const parsed = WithdrawSchema.parse(args ?? {});
|
|
1397
1397
|
const result = await session.requireClient().withdraw(parsed.invoice);
|
|
1398
1398
|
return {
|
|
1399
1399
|
content: [
|
|
@@ -1414,9 +1414,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1414
1414
|
};
|
|
1415
1415
|
}
|
|
1416
1416
|
case 'create_withdraw_link': {
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
|
|
1417
|
+
const { amount_sats: amountSats } = zod_1.z.object({
|
|
1418
|
+
amount_sats: zod_1.z.coerce.number().int().min(100).optional(),
|
|
1419
|
+
}).parse(args ?? {});
|
|
1420
1420
|
const result = await session.requireClient().createWithdrawLink(amountSats);
|
|
1421
1421
|
return {
|
|
1422
1422
|
content: [
|
|
@@ -1439,7 +1439,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1439
1439
|
};
|
|
1440
1440
|
}
|
|
1441
1441
|
case 'sweep_agent': {
|
|
1442
|
-
const parsed = SweepAgentSchema.parse(args);
|
|
1442
|
+
const parsed = SweepAgentSchema.parse(args ?? {});
|
|
1443
1443
|
const amount = typeof parsed.amount_sats === 'string' ? 999999999 : parsed.amount_sats;
|
|
1444
1444
|
const result = await session.requireClient().sweepAgent(parsed.agent_id, amount);
|
|
1445
1445
|
return {
|
|
@@ -1458,7 +1458,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1458
1458
|
};
|
|
1459
1459
|
}
|
|
1460
1460
|
case 'pay_lightning_address': {
|
|
1461
|
-
const parsed = PayLightningAddressSchema.parse(args);
|
|
1461
|
+
const parsed = PayLightningAddressSchema.parse(args ?? {});
|
|
1462
1462
|
const result = await session.requireClient().payLightningAddress(parsed.address, parsed.amount_sats, parsed.comment);
|
|
1463
1463
|
return {
|
|
1464
1464
|
content: [
|
|
@@ -1480,7 +1480,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1480
1480
|
// NOSTR IDENTITY & ZAPS
|
|
1481
1481
|
// ==========================================
|
|
1482
1482
|
case 'set_nostr_identity': {
|
|
1483
|
-
const parsed = SetNostrIdentitySchema.parse(args);
|
|
1483
|
+
const parsed = SetNostrIdentitySchema.parse(args ?? {});
|
|
1484
1484
|
const result = await session.requireClient().setNostrIdentity(parsed.private_key);
|
|
1485
1485
|
return {
|
|
1486
1486
|
content: [
|
|
@@ -1513,7 +1513,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1513
1513
|
};
|
|
1514
1514
|
}
|
|
1515
1515
|
case 'nostr_zap': {
|
|
1516
|
-
const parsed = NostrZapSchema.parse(args);
|
|
1516
|
+
const parsed = NostrZapSchema.parse(args ?? {});
|
|
1517
1517
|
const result = await session.requireClient().nostrZap(parsed.address, parsed.amount_sats, parsed.recipient_pubkey, parsed.content, parsed.event_id, parsed.relays);
|
|
1518
1518
|
return {
|
|
1519
1519
|
content: [
|
|
@@ -1537,7 +1537,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1537
1537
|
// TIER 4: Advanced Agent Management
|
|
1538
1538
|
// ==========================================
|
|
1539
1539
|
case 'transfer_to_agent': {
|
|
1540
|
-
const parsed = TransferToAgentSchema.parse(args);
|
|
1540
|
+
const parsed = TransferToAgentSchema.parse(args ?? {});
|
|
1541
1541
|
const result = await session.requireClient().transferToAgent(parsed.to_agent_id, parsed.amount_sats, parsed.from_agent_id);
|
|
1542
1542
|
return {
|
|
1543
1543
|
content: [
|
|
@@ -1555,7 +1555,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1555
1555
|
};
|
|
1556
1556
|
}
|
|
1557
1557
|
case 'delete_agent': {
|
|
1558
|
-
const parsed = DeleteAgentSchema.parse(args);
|
|
1558
|
+
const parsed = DeleteAgentSchema.parse(args ?? {});
|
|
1559
1559
|
if (!parsed.confirm) {
|
|
1560
1560
|
return {
|
|
1561
1561
|
content: [
|
|
@@ -1588,7 +1588,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1588
1588
|
// TIER 5: Protocol Extensions
|
|
1589
1589
|
// ==========================================
|
|
1590
1590
|
case 'lnurl_auth': {
|
|
1591
|
-
const parsed = LnurlAuthSchema.parse(args);
|
|
1591
|
+
const parsed = LnurlAuthSchema.parse(args ?? {});
|
|
1592
1592
|
const result = await session.requireClient().lnurlAuth(parsed.lnurl);
|
|
1593
1593
|
return {
|
|
1594
1594
|
content: [
|
|
@@ -1604,7 +1604,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1604
1604
|
};
|
|
1605
1605
|
}
|
|
1606
1606
|
case 'claim_lnurl_withdraw': {
|
|
1607
|
-
const parsed = ClaimLnurlWithdrawSchema.parse(args);
|
|
1607
|
+
const parsed = ClaimLnurlWithdrawSchema.parse(args ?? {});
|
|
1608
1608
|
const result = await session.requireClient().claimLnurlWithdraw(parsed.lnurl);
|
|
1609
1609
|
return {
|
|
1610
1610
|
content: [
|
|
@@ -1622,7 +1622,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1622
1622
|
};
|
|
1623
1623
|
}
|
|
1624
1624
|
case 'keysend': {
|
|
1625
|
-
const parsed = KeysendSchema.parse(args);
|
|
1625
|
+
const parsed = KeysendSchema.parse(args ?? {});
|
|
1626
1626
|
const result = await session.requireClient().keysend(parsed.destination, parsed.amount_sats, parsed.message);
|
|
1627
1627
|
return {
|
|
1628
1628
|
content: [
|
|
@@ -1646,7 +1646,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1646
1646
|
// MESSAGE BOARD TOOL HANDLERS
|
|
1647
1647
|
// ==========================================
|
|
1648
1648
|
case 'board_read': {
|
|
1649
|
-
const parsed = BoardReadSchema.parse(args);
|
|
1649
|
+
const parsed = BoardReadSchema.parse(args ?? {});
|
|
1650
1650
|
const result = await session.requireClient().boardRead(parsed.sort, parsed.topic, parsed.limit, parsed.offset);
|
|
1651
1651
|
return {
|
|
1652
1652
|
content: [
|
|
@@ -1658,7 +1658,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1658
1658
|
};
|
|
1659
1659
|
}
|
|
1660
1660
|
case 'board_post': {
|
|
1661
|
-
const parsed = BoardPostSchema.parse(args);
|
|
1661
|
+
const parsed = BoardPostSchema.parse(args ?? {});
|
|
1662
1662
|
const result = await session.requireClient().boardPost(parsed.content, parsed.topic);
|
|
1663
1663
|
return {
|
|
1664
1664
|
content: [
|
|
@@ -1670,7 +1670,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1670
1670
|
};
|
|
1671
1671
|
}
|
|
1672
1672
|
case 'board_reply': {
|
|
1673
|
-
const parsed = BoardReplySchema.parse(args);
|
|
1673
|
+
const parsed = BoardReplySchema.parse(args ?? {});
|
|
1674
1674
|
const result = await session.requireClient().boardReply(parsed.post_id, parsed.content);
|
|
1675
1675
|
return {
|
|
1676
1676
|
content: [
|
|
@@ -1682,7 +1682,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1682
1682
|
};
|
|
1683
1683
|
}
|
|
1684
1684
|
case 'board_vote': {
|
|
1685
|
-
const parsed = BoardVoteSchema.parse(args);
|
|
1685
|
+
const parsed = BoardVoteSchema.parse(args ?? {});
|
|
1686
1686
|
const result = await session.requireClient().boardVote(parsed.post_id, parsed.direction);
|
|
1687
1687
|
return {
|
|
1688
1688
|
content: [
|
|
@@ -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
|
*/
|
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,
|
|
@@ -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.1",
|
|
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": [
|