diff --git a/SECURITY.md b/SECURITY.md index def0d8f..b2ae5d0 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 1ef32ea..06eef21 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,72 @@ 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. + // The try-catch covers both decode and amount extraction: BigInt() + // can throw if the decoder returns an unexpected value type. + let amountMsat: bigint; + try { + 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; + } catch (decodeErr) { + throw new Error( + `Invoice could not be decoded: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}` + ); + } + + if (amountMsat === 0n) { + throw new Error( + "Invoice has no amount; refusing to pay amountless invoices for safety." + ); + } + 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." + ); + } + + // 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", amountMsat / 1000n, addr); + 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. + // 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", amountMsat / 1000n, addr); + } catch (recordErr) { + console.error( + `[spend-limit] record() failed after confirmed Lightning payment ` + + `(${amountMsat / 1000n} sats, addr=${addr}): ` + + `${recordErr instanceof Error ? recordErr.message : String(recordErr)}` + ); + } + return createJsonResponse({ success: true, preimage: result.preimage,