From ae7e17d8556166336bd1d3bd8c6eb8fab15a7111 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 10:26:24 +0200 Subject: [PATCH 1/4] fix(lightning_pay_invoice): enforce spend-limiter before paying BOLT-11 invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual `lightning_pay_invoice` tool bypassed the wallet spending cap (src/services/spend-limiter.ts, added in #571), allowing Lightning payments to exceed daily/session sats budgets unmetered. This was flagged as finding F3 in the security audit and deferred from #571. Changes: - Decode the BOLT-11 invoice amount (reusing `light-bolt11-decoder`, already a project dependency, and the same decode pattern as x402.service.ts). - Reject amountless invoices (consistent with the L402 auto-pay path). - Call `getSpendLimiter().check("sats", amountSats, addr)` before paying. - Call `getSpendLimiter().record("sats", amountSats, addr)` after confirmed pay. - Ledger key: active Stacks address when unlocked; falls back to `__lightning__` so the cap is always enforced even when the main wallet is locked (option (a) from the issue's open question, documented inline). Closes #572 [![Early Eagle #0 — Legendary](https://early-eagles.vercel.app/api/badge/SP3JR7JXFT7ZM9JKSQPBQG1HPT0D365MA5TN0P12E?alias=Iskander)](https://early-eagles.vercel.app/eagle/0) --- src/tools/lightning.tools.ts | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/tools/lightning.tools.ts b/src/tools/lightning.tools.ts index 1ef32ea0..11bd4691 100644 --- a/src/tools/lightning.tools.ts +++ b/src/tools/lightning.tools.ts @@ -9,8 +9,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { decode as decodeBolt11 } from "light-bolt11-decoder"; import { createJsonResponse, createErrorResponse } from "../utils/index.js"; import { getLightningManager } from "../services/lightning-manager.js"; +import { getSpendLimiter } from "../services/spend-limiter.js"; import { getWalletManager } from "../services/wallet-manager.js"; import { MempoolApi, @@ -369,7 +371,45 @@ export function registerLightningTools(server: McpServer): void { "Lightning wallet is locked. Use lightning_unlock first." ); } + + // Decode the BOLT-11 invoice to get the payable amount in sats. + // Same pattern as the L402 auto-pay path in x402.service.ts. + let decoded: ReturnType; + try { + decoded = decodeBolt11(bolt11); + } catch (decodeErr) { + throw new Error( + `Invoice could not be decoded: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}` + ); + } + + const amountSection = decoded.sections.find( + (s): s is { name: "amount"; letters: string; value: string } => + s.name === "amount" + ); + const amountMsat = amountSection?.value + ? BigInt(amountSection.value) + : 0n; + const amountSats = Number(amountMsat / 1000n); + if (amountSats === 0) { + throw new Error( + "Invoice has no amount; refusing to pay amountless invoices for safety." + ); + } + + // Enforce cumulative spending cap (sats ledger). + // Key by the active Stacks address when available; fall back to a + // stable Lightning-specific key so the cap is always enforced even + // when the main wallet is locked (separate budget bucket in that case). + const addr = + getWalletManager().getActiveAccount()?.address ?? "__lightning__"; + await getSpendLimiter().check("sats", BigInt(amountSats), addr); + const result = await provider.payInvoice(bolt11, maxFeeSats); + + // Record the spend only after a confirmed payment. + await getSpendLimiter().record("sats", BigInt(amountSats), addr); + return createJsonResponse({ success: true, preimage: result.preimage, From 6ff62798be611612bc4f73b4efc33cc28bab1364 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 10:36:58 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(lightning=5Fpay=5Finvoice):=20address?= =?UTF-8?q?=20arc0btc=20review=20=E2=80=94=20decode=20safety=20+=20sub-sat?= =?UTF-8?q?=20+=20preimage=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes from the #575 review: 1. Extend try-catch to cover amount extraction — BigInt(amountSection.value) was outside the original catch block and could throw an unhandled runtime error on unexpected decoder output. Both decode and extraction now share one catch with a descriptive message. 2. Distinguish amountless vs sub-sat invoices — amountMsat === 0n is "no amount section"; amountSats === 0 with amountMsat > 0n is a sub-sat invoice (< 1000 msat) that cannot be metered in whole sats. Separate error messages for each case. 3. Preimage guard after payInvoice() — spark-provider already throws on a missing preimage, but a defensive guard + inline comment documents the throw contract and protects against future provider implementations. --- src/tools/lightning.tools.ts | 38 +++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/tools/lightning.tools.ts b/src/tools/lightning.tools.ts index 11bd4691..43b3b4e3 100644 --- a/src/tools/lightning.tools.ts +++ b/src/tools/lightning.tools.ts @@ -374,28 +374,37 @@ export function registerLightningTools(server: McpServer): void { // Decode the BOLT-11 invoice to get the payable amount in sats. // Same pattern as the L402 auto-pay path in x402.service.ts. - let decoded: ReturnType; + // The try-catch covers both decode and amount extraction: BigInt() + // can throw if the decoder returns an unexpected value type. + let amountSats: number; + let amountMsat: bigint; try { - decoded = decodeBolt11(bolt11); + const decoded = decodeBolt11(bolt11); + const amountSection = decoded.sections.find( + (s): s is { name: "amount"; letters: string; value: string } => + s.name === "amount" + ); + amountMsat = amountSection?.value + ? BigInt(amountSection.value) + : 0n; + amountSats = Number(amountMsat / 1000n); } catch (decodeErr) { throw new Error( `Invoice could not be decoded: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}` ); } - const amountSection = decoded.sections.find( - (s): s is { name: "amount"; letters: string; value: string } => - s.name === "amount" - ); - const amountMsat = amountSection?.value - ? BigInt(amountSection.value) - : 0n; - const amountSats = Number(amountMsat / 1000n); - if (amountSats === 0) { + if (amountMsat === 0n) { throw new Error( "Invoice has no amount; refusing to pay amountless invoices for safety." ); } + if (amountSats === 0) { + // amountMsat > 0 but < 1000: sub-sat invoice, unmeterable. + throw new Error( + "Invoice amount is below 1 sat minimum (< 1000 msat); refusing to pay." + ); + } // Enforce cumulative spending cap (sats ledger). // Key by the active Stacks address when available; fall back to a @@ -407,6 +416,13 @@ export function registerLightningTools(server: McpServer): void { const result = await provider.payInvoice(bolt11, maxFeeSats); + // spark-provider.payInvoice() throws on any failure (including a + // missing preimage), so result.preimage is always set here. The guard + // below is an extra safety net against future provider implementations. + if (!result.preimage) { + throw new Error("Payment failed: no preimage returned."); + } + // Record the spend only after a confirmed payment. await getSpendLimiter().record("sats", BigInt(amountSats), addr); From 6fcb6de96539cbc226d66f0ef4849db89806dce3 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 12:26:57 +0200 Subject: [PATCH 3/4] fix(lightning_pay_invoice): record() fault-tolerance + SECURITY.md two-bucket docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two follow-up observations from the secret-mars review on #575: 1. record() fault-tolerance: getSpendLimiter().record() does a file-system read-modify-write. If it throws after payInvoice() returns (transient disk failure, etc.), the Lightning payment has already settled and is irreversible. Wrapping in try/catch + console.error logs the ledger gap without misleading the caller into thinking the payment failed. The state file reconciles on the next successful write. 2. SECURITY.md two-bucket semantics: documents that lightning_pay_invoice spends fall under a separate __lightning__ budget bucket when the STX wallet is locked, meaning total daily sats exposure can approach 2× SPEND_LIMIT_DAILY_SATS if the STX wallet is locked mid-session. Added mitigation guidance (conservative cap, cold storage). Also updated the spending-limit bullet to include lightning_pay_invoice alongside the other metered paths. --- SECURITY.md | 25 ++++++++++++++++++------- src/tools/lightning.tools.ts | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index def0d8f7..b2ae5d02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -101,13 +101,24 @@ it. Keep GitHub push protection on as the enforcement those workflows can't skip ### Limit blast radius - **Spending limit (default-on).** Every outbound spend path — `transfer_stx`, - `transfer_btc`, x402/L402 auto-payments — is metered against a cumulative - per-session and per-day cap (default ~10 STX + ~50k sats). A spend that would - exceed it is blocked and surfaces the remaining budget, so a single - prompt-injected call (or a malicious endpoint looping sub-cap payments) can't - drain the wallet. Override per wallet with `SPEND_LIMIT_DAILY_USTX` / - `SPEND_LIMIT_SESSION_USTX` / `SPEND_LIMIT_DAILY_SATS` / - `SPEND_LIMIT_SESSION_SATS`, or disable with `SPEND_LIMIT_ENABLED=false`. + `transfer_btc`, x402/L402 auto-payments, and `lightning_pay_invoice` — is + metered against a cumulative per-session and per-day cap (default ~10 STX + + ~50k sats). A spend that would exceed it is blocked and surfaces the remaining + budget, so a single prompt-injected call (or a malicious endpoint looping + sub-cap payments) can't drain the wallet. Override per wallet with + `SPEND_LIMIT_DAILY_USTX` / `SPEND_LIMIT_SESSION_USTX` / + `SPEND_LIMIT_DAILY_SATS` / `SPEND_LIMIT_SESSION_SATS`, or disable with + `SPEND_LIMIT_ENABLED=false`. + + **Two-bucket Lightning semantics.** The sats ledger is keyed by the active + Stacks address so BTC L1, sBTC, and Lightning spends share one budget. When + the STX wallet is locked (`wallet_lock`) but the Lightning wallet is still + active, `lightning_pay_invoice` spends are metered against a separate + `__lightning__` bucket instead. Both buckets enforce the same cap + independently — this means total daily exposure can approach 2× the + `SPEND_LIMIT_DAILY_SATS` cap if the STX wallet is locked mid-session. To + bound this: keep funds in cold storage and set a conservative + `SPEND_LIMIT_DAILY_SATS`, or use `wallet_lock` only when done transacting. - Keep only working funds in the agent's hot wallet; hold the rest in cold storage. - The x402 payment flow also enforces a per-transaction spend cap. - Wallets auto-lock after an idle timeout (`wallet_set_timeout`); lock manually diff --git a/src/tools/lightning.tools.ts b/src/tools/lightning.tools.ts index 43b3b4e3..af3aa685 100644 --- a/src/tools/lightning.tools.ts +++ b/src/tools/lightning.tools.ts @@ -424,7 +424,20 @@ export function registerLightningTools(server: McpServer): void { } // Record the spend only after a confirmed payment. - await getSpendLimiter().record("sats", BigInt(amountSats), addr); + // record() performs a file-system read-modify-write; if it throws + // (e.g. transient KV/disk failure), the Lightning payment has already + // settled and cannot be reversed. We log the ledger gap rather than + // propagating an error that would mislead the caller into thinking the + // payment failed. The daily state file reconciles on the next write. + try { + await getSpendLimiter().record("sats", BigInt(amountSats), addr); + } catch (recordErr) { + console.error( + `[spend-limit] record() failed after confirmed Lightning payment ` + + `(${amountSats} sats, addr=${addr}): ` + + `${recordErr instanceof Error ? recordErr.message : String(recordErr)}` + ); + } return createJsonResponse({ success: true, From a7c12a0cbd7ff3d70879322344a3158dac1322cf Mon Sep 17 00:00:00 2001 From: Iskander Date: Wed, 24 Jun 2026 23:17:07 +0200 Subject: [PATCH 4/4] refactor(lightning_pay_invoice): drop amountSats, pass amountMsat/1000n directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses arc0btc review nits (2026-06-24): - Remove the Number(amountMsat/1000n) round-trip and BigInt(amountSats) re-cast; pass amountMsat / 1000n directly to check() and record(). - Replace amountSats === 0 guard with amountMsat < 1000n — same semantics, no number conversion needed. - Remove amountSats variable entirely; all sites now use the BigInt directly. Co-Authored-By: Claude Sonnet 4.6 --- src/tools/lightning.tools.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/tools/lightning.tools.ts b/src/tools/lightning.tools.ts index af3aa685..06eef215 100644 --- a/src/tools/lightning.tools.ts +++ b/src/tools/lightning.tools.ts @@ -376,7 +376,6 @@ export function registerLightningTools(server: McpServer): void { // Same pattern as the L402 auto-pay path in x402.service.ts. // The try-catch covers both decode and amount extraction: BigInt() // can throw if the decoder returns an unexpected value type. - let amountSats: number; let amountMsat: bigint; try { const decoded = decodeBolt11(bolt11); @@ -387,7 +386,6 @@ export function registerLightningTools(server: McpServer): void { amountMsat = amountSection?.value ? BigInt(amountSection.value) : 0n; - amountSats = Number(amountMsat / 1000n); } catch (decodeErr) { throw new Error( `Invoice could not be decoded: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}` @@ -399,7 +397,7 @@ export function registerLightningTools(server: McpServer): void { "Invoice has no amount; refusing to pay amountless invoices for safety." ); } - if (amountSats === 0) { + if (amountMsat < 1000n) { // amountMsat > 0 but < 1000: sub-sat invoice, unmeterable. throw new Error( "Invoice amount is below 1 sat minimum (< 1000 msat); refusing to pay." @@ -412,7 +410,7 @@ export function registerLightningTools(server: McpServer): void { // when the main wallet is locked (separate budget bucket in that case). const addr = getWalletManager().getActiveAccount()?.address ?? "__lightning__"; - await getSpendLimiter().check("sats", BigInt(amountSats), addr); + await getSpendLimiter().check("sats", amountMsat / 1000n, addr); const result = await provider.payInvoice(bolt11, maxFeeSats); @@ -430,11 +428,11 @@ export function registerLightningTools(server: McpServer): void { // propagating an error that would mislead the caller into thinking the // payment failed. The daily state file reconciles on the next write. try { - await getSpendLimiter().record("sats", BigInt(amountSats), addr); + await getSpendLimiter().record("sats", amountMsat / 1000n, addr); } catch (recordErr) { console.error( `[spend-limit] record() failed after confirmed Lightning payment ` + - `(${amountSats} sats, addr=${addr}): ` + + `(${amountMsat / 1000n} sats, addr=${addr}): ` + `${recordErr instanceof Error ? recordErr.message : String(recordErr)}` ); }