Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions src/tools/lightning.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down