feat(lightning): add L402 payment rail via Spark SDK#474
Conversation
Adds embedded Lightning wallet support alongside the existing x402-stacks payment rail. Uses @buildonspark/spark-sdk for a self-custodial wallet that requires no API key and can be funded from the user's existing L1 BTC. - Spark-backed LightningProvider with pay/receive/deposit operations - L402 interceptor in src/services/x402.service.ts (auto-pays LN invoices on HTTP 402 with WWW-Authenticate: L402 ...) - Encrypted session storage at ~/.aibtc/lightning/keystore.json - 7 new tools: lightning_create/import/unlock/lock/status, fund_from_btc, pay_invoice, create_invoice - L1 BTC → LN funding reuses the existing transfer_btc signing path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arc0btc
left a comment
There was a problem hiding this comment.
Adds a self-custodial Lightning wallet backed by Spark SDK and wires it into the axios pipeline as an L402 payment rail — a clean addition to the payment infrastructure. The provider abstraction (LightningProvider interface + SparkLightningProvider impl) is well-structured and leaves a clear seam for the NWC escape-hatch planned in PR 2.
What works well:
- Provider interface/impl separation in
src/services/lightning/— the abstraction is solid and will make the NWC provider drop-in straightforward - Atomic keystore writes via temp-file + rename in
writeKeystore— same pattern as the Stacks wallet, good consistency - Rail preference logic in the interceptor (
x402-stacks if Stacks wallet is unlocked, otherwise L402) correctly preserves the existing payment priority - Macaroon cache design (in-memory Map, keyed by
METHOD:URL) is simple and correct for PR 1 scope - LSAT legacy scheme alias in
parseL402Challenge— good forward/backward compatibility
[blocking] l402Attempts counter never resets — will reject L402 on any second endpoint (src/services/x402.service.ts)
The guard uses a per-client-instance WeakMap counter:
const attempts = l402Attempts.get(axiosInstance) ?? 0;
if (attempts >= 1) { return Promise.reject(...); }
l402Attempts.set(axiosInstance, attempts + 1);After a successful L402 payment to endpoint A, l402Attempts for the instance is set to 1 and stays there. Any subsequent 402 on a different endpoint B using the same axiosInstance is immediately rejected with "retry limit exceeded" — even on a first attempt to B.
The cache-hit path avoids incrementing (it returns before the guard), but only for the exact same METHOD:URL. A new L402-protected endpoint on the same client will always hit this wall.
The existing x402-stacks counter has this same shape but it's less of an issue there because x402-stacks is typically one-payment-per-session. L402 is per-endpoint, so multiple payment attempts are the expected pattern.
Fix: key the counter by method:url instead of by instance, or reset the counter to 0 after a successful response:
// Guard against L402 retry loops.
const attemptKey = `${(error.config?.method ?? "get").toUpperCase()}:${error.config?.url ?? ""}`;
const attempts = l402Attempts.get(axiosInstance) ?? new Map<string, number>();
const urlAttempts = typeof attempts === "number" ? 0 : (attempts.get(attemptKey) ?? 0);
Alternatively, a simpler fix: clear the counter in the success interceptor (first argument to interceptors.response.use).
[suggestion] invalidateL402Auth is defined but never called — stale cache survives rejected preimages (src/utils/l402-protocol.ts)
invalidateL402Auth exists to handle the case where a server returns 402 again after a cached preimage was submitted. But the interceptor only calls getCachedL402Auth / cacheL402Auth — it never calls invalidateL402Auth. If a server invalidates a macaroon, the cached entry persists forever and will keep failing silently (the retry-loop guard fires on the second 402 hit, not the cache stale case).
Add an invalidation call when a cache-hit retry returns 402:
// Cache hit: reuse the macaroon+preimage without paying.
const cached = getCachedL402Auth(method, requestUrl);
if (cached) {
const originalRequest = error.config;
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers["Authorization"] = buildL402AuthHeader(
cached.macaroon,
cached.preimage
);
// If the server rejects this (402 again), the cache entry is stale.
return axiosInstance.request(originalRequest).catch((retryErr) => {
if (retryErr?.response?.status === 402) {
invalidateL402Auth(method, requestUrl);
}
return Promise.reject(retryErr);
});
}
You'll also need to import invalidateL402Auth at the top.
[suggestion] Adversarial 402 — a malicious server can drain the LN wallet via invoice injection (src/services/x402.service.ts)
The invoice field in the L402 challenge is parsed from the HTTP response and passed directly to lnProvider.payInvoice. Any server that returns a 402 with an arbitrary invoice in WWW-Authenticate will cause the wallet to pay it. This is a meaningful risk if users connect to untrusted MCP endpoints.
Consider adding a payment cap (max sats per auto-pay) or surfacing the invoice amount for user confirmation before paying anything above a threshold. Even a simple max like DEFAULT_MAX_FEE_SATS applied to the invoice amount would limit blast radius.
[question] testnet → REGTEST mapping in toSparkNetwork (src/services/lightning/spark-provider.ts)
function toSparkNetwork(network: Network): "MAINNET" | "REGTEST" {
return network === "mainnet" ? "MAINNET" : "REGTEST";
}Does Spark's REGTEST have real Lightning routing, or is it isolated to local nodes? If users set NETWORK=testnet expecting to test with real LN testnet invoices, they may be surprised to find REGTEST has no connectivity. Worth a doc note in CLAUDE.md or a warning in lightning_create / lightning_unlock when network !== mainnet.
Code quality notes:
[nit] extractParam in l402-protocol.ts builds a regex from the caller-supplied key string without escaping. Keys are currently hardcoded ("macaroon", "invoice") so there's no injection risk today, but the pattern is fragile. If a future caller passes a key with regex special characters, the regex silently breaks. Consider using escapeRegExp or switching to a simple string split/indexOf approach.
[nit] lightning_lock is registered without an inputSchema key at all. The other tools pass an explicit inputSchema: {} (or object). Verify the MCP SDK accepts a missing inputSchema without throwing — if the SDK enforces schema presence, this will silently fail to register.
Operational context:
We run the x402-stacks rail heavily through this codebase (all our x402 relay traffic goes through createApiClient). The L402 interceptor being registered before the x402 chain is the right order — but I'd flag one subtle edge case we've seen in production: x402 decodePaymentRequired can return null on malformed headers even when a payment is required. The guard hasStacksOption && stacksWalletUnlocked will correctly fall through to L402 in that case, which is the desired behavior.
The 142 MB Spark SDK footprint is real — worth confirming CI still passes and Docker image size doesn't breach any fleet limits before merging.
- Per-URL L402 retry counter (fixes rejection of second L402 endpoint) - Reject L402 invoices above L402_MAX_SATS_PER_INVOICE cap (default 10000) - Reject amountless L402 invoices for safety - Invalidate cached macaroon+preimage when server rejects retry with 402 - Escape regex special chars in extractParam for future-proofing Does NOT change: - testnet→REGTEST mapping (REGTEST is Spark's official test network per docs.spark.money) - lightning_lock inputSchema omission (matches existing wallet_lock pattern) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @arc0btc — good review. Fix commit pushed in cf31f5c addressing the four valid items:
Two items we're not changing, with reasoning:
Despite the name (which in Bitcoin convention suggests local-only), Spark's REGTEST is their public hosted test environment. It's their recommended non-mainnet option and is the right mapping for our
server.registerTool(
"wallet_lock",
{ description: "Lock the agent's wallet, clearing sensitive data from memory." },
async () => { ... }
);The MCP SDK accepts registration without Also flagging: Ready for another pass when you get a chance. |
…ected The L402 interceptor was calling getWalletManager().isUnlocked() on every 402 response to decide whether to defer to the x402-stacks chain. Tests that mock the wallet manager without isUnlocked (pre-existing x402-only tests) then fail with a TypeError. Reorder so the interceptor checks for a WWW-Authenticate: L402 challenge first. No challenge → immediate pass-through to x402 chain, no wallet manager access. Rail-preference logic only runs when L402 is actually applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bolt11 pulls in secp256k1 → elliptic <= 6.6.1 (GHSA-848j-6mx2-7j84), which trips Snyk. We only needed invoice amount decoding, not signature verification, so light-bolt11-decoder is a better fit: 59 KB, single dependency (@scure/base, already in tree), no CVEs. light-bolt11-decoder returns the amount in millisats rather than sats; converted before comparing to L402_MAX_SATS_PER_INVOICE. Amountless invoice detection (no amount section / zero value) still rejects with the same user-facing error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arc0btc
left a comment
There was a problem hiding this comment.
All three blocking/suggestion items from my previous review are addressed — approving.
What was fixed:
-
l402Attemptscounter — now aWeakMap<AxiosInstance, Map<string, number>>keyed byMETHOD:URL. Endpoint A's retry limit no longer bleeds into endpoint B.clearL402AttemptCountin the success interceptor resets the per-URL budget after a clean response. -
invalidateL402Authwired on cache-hit retry — the cache-hit path now catches a follow-up 402 and callsinvalidateL402Authto evict the stale entry. Exactly what was needed. -
Invoice amount cap —
L402_MAX_SATS_PER_INVOICE(10k sats default, env-overridable) withlight-bolt11-decoderparsing before anypayInvoicecall. Bounds the blast radius on a malicious BOLT-11 from an untrusted server. Thebolt11→light-bolt11-decoderswap also clears the Snyk CVE.
[nit] testnet → REGTEST mapping still undocumented (src/services/lightning/spark-provider.ts)
The open question from my first review — whether Spark REGTEST has real LN routing — is still unanswered in code or docs. Not blocking merge, but worth a one-liner warning in lightning_unlock or lightning_status when NETWORK !== mainnet so users are not surprised.
Operational note: We use createApiClient for all x402 relay calls. L402 interceptor registered before the x402 chain is the right order. The 142 MB Spark SDK is a real addition to the server footprint — confirm container start time in staging before promoting to prod.
…lidation
- claimDeposit: SDK's claimStaticDepositWithMaxFee returns
{ transferId } — no creditAmountSats. Now calls
getClaimStaticDepositQuote first to capture creditAmountSats, then
claims, and returns { creditedSats, transferId }. Adds optional
outputIndex parameter. Provider interface updated.
- payInvoice: Spark's CurrencyAmount has originalUnit ∈ {SATOSHI,
MILLISATOSHI, BITCOIN, MILLIBITCOIN, MICROBITCOIN, NANOBITCOIN, ...}.
Previously read originalValue blindly as sats — could mis-report fees
by 1000× on MILLISATOSHI. Added internal currencyAmountToSats helper
with explicit unit handling; throws on fiat/FUTURE_VALUE.
- L402 cache: retry-after-pay now invalidates the macaroon entry on
402, mirroring the cache-hit path. Prevents permanent dead entries
when the server rejects a freshly minted preimage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claimDeposit was using claimStaticDepositWithMaxFee (a convenience wrapper that internally fetches the SSP quote) AND fetching the quote explicitly to capture creditAmountSats — two redundant quote requests per claim, with a tiny race window between them. Switch to the canonical flow shown at docs.spark.money/wallets/deposit-from-l1: fetch quote once (returns creditAmountSats + signature), then call claimStaticDeposit with the quote's signature. Single quote request, SSP-signed creditAmountSats matches what was actually claimed. Drops the maxFeeSats parameter from the LightningProvider interface since the SSP-signed quote IS the agreement. Callers that need a max-fee guard should fetch and inspect the quote before invoking claimDeposit. claimDeposit is currently dead code (no tool wires it), so this is a safe interface change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Lightning Network payment support to the existing x402 client pipeline by implementing the L402 protocol (challenge parsing, invoice auto-pay, retry with Authorization) and introducing a Spark SDK–backed embedded Lightning wallet with MCP tools for wallet lifecycle and invoice operations.
Changes:
- Added L402 protocol helpers + in-memory macaroon/preimage cache.
- Added Spark-backed Lightning wallet manager/provider and exposed new
lightning_*MCP tools (create/import/unlock/lock/status/fund/pay/create-invoice). - Extended
createApiClientresponse interceptor chain to handleWWW-Authenticate: L402 ...402 challenges with safety caps and retry behavior.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/l402-protocol.ts | L402 challenge parsing, auth header construction, in-memory macaroon cache helpers. |
| src/services/x402.service.ts | Adds an L402 402-interceptor (auto-pay + retry) ahead of existing x402 logic. |
| src/services/lightning/spark-provider.ts | Implements LightningProvider using @buildonspark/spark-sdk. |
| src/services/lightning/provider.ts | Defines a backend-agnostic Lightning provider interface used by tools/interceptors. |
| src/services/lightning-manager.ts | Adds encrypted keystore + session management for the Lightning wallet. |
| src/tools/lightning.tools.ts | New MCP tools for Lightning wallet lifecycle, funding from BTC, and invoices. |
| src/tools/index.ts | Registers the new Lightning tools in the global tool registry. |
| package.json | Adds Spark SDK + BOLT11 decoder dependencies. |
| package-lock.json | Locks new dependency tree for Spark SDK + decoder. |
| CLAUDE.md | Documents the new Lightning/L402 capability and tool surface area. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Validate L402_MAX_SATS_PER_INVOICE env var; NaN/non-finite/<=0 falls back to default (was a security bypass — invalid env disabled the cap) - Reject NETWORK=testnet for Lightning ops with a clear error; Spark's REGTEST cannot interoperate with Bitcoin testnet (lightning_fund_from_btc was silently broken on testnet) - Add lightning_claim_deposit tool to complete the L1->LN funding flow - Initialize Spark provider before persisting keystore in lightning-manager so init failures don't leave orphaned encrypted blobs blocking retries - Invalidate L402 macaroon cache on 401/403 in addition to 402; many servers signal stale credentials with non-402 errors - Use absolute URL (baseURL + url) for L402 attempt counter and macaroon cache keys to prevent cross-service collision when one path is hosted on multiple base URLs - parseL402Challenge: support single-quoted parameter values to match the docstring claim (RFC-7235 quoted-string semantics) - New tests/services/l402-interceptor.test.ts covering header parsing, amount-cap, amountless rejection, cache reuse, cache invalidation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
whoabuddy
left a comment
There was a problem hiding this comment.
Audited end-to-end against the latest commit (ea20783). Approving.
Verified in code:
- Per-URL
l402Attemptskeying with reset on 200 (x402.service.ts:89,370-378) — fixes the "endpoint A poisons endpoint B" blocker invalidateL402Authwired on cache-hit AND fresh-payment retry paths, on 401/402/403 (457-463,538-544) — handles servers that signal stale credentials with non-402 codes- Canonical absolute URL (
baseURL + url) used for both attempt counter and macaroon cache key (canonicalRequestUrlat336-340) — prevents cross-service collisions when two clients hit the same path on different hosts L402_MAX_SATS_PER_INVOICEparsed viaparseSatsCapwith NaN/non-finite/≤0 fallback to default (62-77) — closes the silent cap-bypass whereNumber(undefined)makesamountSats > NaNalways false- Amountless invoices rejected before payment (
491-497) testnetrejected at the Spark boundary with a clear error (spark-provider.ts:25-32) — replaces silent REGTEST mapping that would have sent L1 testnet tx to a different chainstoreWalletinitializes Spark provider BEFORE persisting keystore (lightning-manager.ts:138-171) — prevents orphaned encrypted blobs blocking retries on init failureescapeRegExpapplied inextractParam; single-quoted values now supported to match docstring (l402-protocol.ts:59-89)lightning_claim_deposittool exists and uses canonical SSP quote→sign→claim flow
Local verification:
npm run buildcleannpm test— 472/472 passing, including 5 new L402 interceptor tests covering header parsing, amount-cap enforcement, amountless rejection, cache reuse, and cache invalidation on stale 401- All 7 GitHub checks green (CI, CodeQL ×3, Snyk)
Non-blocking notes for follow-up:
- 142 MB Spark SDK footprint — worth confirming container start time after first prod deploy
- Mnemonic UX (per my earlier comment) — fold Lightning setup into the main wallet flow in a follow-up PR
- Disk-backed macaroon cache + NWC provider explicitly scoped to PR 2/3 per the gist
Solid PR 1 of the series. Provider abstraction leaves a clean seam for the NWC drop-in.

Summary
Adds Lightning Network payment support to aibtc-mcp via the L402 protocol, using
@buildonspark/spark-sdkas the embedded self-custodial wallet provider.This is PR 1 of a three-PR series — see the full plan gist for the scope breakdown.
Why Spark SDK
transfer_btctoolWhat this adds
New tools
lightning_create,lightning_import,lightning_unlock,lightning_lock,lightning_statuslightning_fund_from_btc— sends from the user's L1 BTC wallet to the Spark deposit addresslightning_pay_invoice,lightning_create_invoiceL402 auto-pay
The existing
createApiClientaxios pipeline now handlesHTTP 402 WWW-Authenticate: L402 ...challenges: parse → pay via Spark → retry withAuthorization: L402 <macaroon>:<preimage>. Cached macaroons are reused until the server invalidates. The x402-stacks interceptor is untouched.Out of scope for this PR (planned for PR 2/3)
lightning_fund_from_btcon-chain confirmation (for now: document as a manual follow-up)Caveats
@buildonspark/spark-sdkunpacks to ~142 MB innode_modules. Worth discussing whether to proxy through a thinner wrapper or accept the size.Test plan
npm run buildpasseslightning_createproduces a wallet, returns deposit address + mnemoniclightning_importround-trips the same mnemoniclightning_fund_from_btcsends L1 BTC to the deposit address (testnet first)🤖 Generated with Claude Code