Skip to content

feat(lightning): add L402 payment rail via Spark SDK#474

Merged
biwasxyz merged 7 commits intomainfrom
feat/lightning-l402
Apr 27, 2026
Merged

feat(lightning): add L402 payment rail via Spark SDK#474
biwasxyz merged 7 commits intomainfrom
feat/lightning-l402

Conversation

@biwasxyz
Copy link
Copy Markdown
Collaborator

Summary

Adds Lightning Network payment support to aibtc-mcp via the L402 protocol, using @buildonspark/spark-sdk as 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

  • No API key required — auth is a challenge signed by the user's BIP39 identity key (inspected the SDK source to confirm)
  • Self-custodial — user holds the mnemonic, encrypted the same way as the existing Stacks wallet
  • Lightning-interoperable — pays real LN invoices (L402 works out of the box)
  • L1 BTC fundable — static deposit addresses accept on-chain BTC; funding reuses the existing transfer_btc tool

What this adds

New tools

  • lightning_create, lightning_import, lightning_unlock, lightning_lock, lightning_status
  • lightning_fund_from_btc — sends from the user's L1 BTC wallet to the Spark deposit address
  • lightning_pay_invoice, lightning_create_invoice

L402 auto-pay

The existing createApiClient axios pipeline now handles HTTP 402 WWW-Authenticate: L402 ... challenges: parse → pay via Spark → retry with Authorization: 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)

  • Disk-backed macaroon cache
  • NWC provider (bring-your-own-wallet escape hatch)
  • Rail preference field on the endpoint registry
  • Auto-poll/claim after lightning_fund_from_btc on-chain confirmation (for now: document as a manual follow-up)

Caveats

  • @buildonspark/spark-sdk unpacks to ~142 MB in node_modules. Worth discussing whether to proxy through a thinner wrapper or accept the size.
  • Spark is a Bitcoin L2 with its own SSP (Spark Service Provider) infrastructure. Wallet of Satoshi migrated to Spark; Breez ships a Spark-based SDK. The ecosystem is live, but this is a newer dependency than the Stacks side.

Test plan

  • npm run build passes
  • lightning_create produces a wallet, returns deposit address + mnemonic
  • lightning_import round-trips the same mnemonic
  • lightning_fund_from_btc sends L1 BTC to the deposit address (testnet first)
  • Hit a test L402 endpoint → verify silent auto-pay + 200 response
  • Macaroon cache reuse on second call to same endpoint

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@biwasxyz
Copy link
Copy Markdown
Collaborator Author

Thanks @arc0btc — good review. Fix commit pushed in cf31f5c addressing the four valid items:

  1. Per-URL retry counterl402Attempts changed from WeakMap<AxiosInstance, number> to WeakMap<AxiosInstance, Map<string, number>> keyed by ${method}:${url}. Counter also clears on 200 OK so long-lived clients don't accumulate entries. Second L402 endpoint now works as expected.

  2. Invoice amount cap — Added L402_MAX_SATS_PER_INVOICE env var (default 10,000). Decoded invoice amount is checked before payment; amountless invoices and over-cap invoices are rejected with clear errors. Uses bolt11 npm package for BOLT11 decoding.

  3. invalidateL402Auth wired up — Cache-hit retry now catches 402 and drops the stale entry so the next call re-pays with a fresh macaroon.

  4. Regex escape in extractParam — Added small escapeRegExp helper, applied at the regex-building sites.


Two items we're not changing, with reasoning:

testnet → REGTEST mapping — This is intentional. Per Spark's official docs:

"Always use REGTEST for development and testing. Only use MAINNET for production applications with real Bitcoin."

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 NETWORK=testnet config. I'll add a doc note to CLAUDE.md in a follow-up clarifying the naming, but the code mapping stays.

lightning_lock without inputSchema — This matches the existing wallet_lock pattern already in production (src/tools/wallet-management.tools.ts):

server.registerTool(
  "wallet_lock",
  { description: "Lock the agent's wallet, clearing sensitive data from memory." },
  async () => { ... }
);

The MCP SDK accepts registration without inputSchema for zero-argument tools; wallet_lock has been shipping this way without issue. Keeping the convention consistent across lock tools.


Also flagging: bolt11 installed with 4 transitive vulnerabilities (3 low, 1 high). Didn't run npm audit fix in this commit to keep the diff focused — will address separately.

Ready for another pass when you get a chance.

biwasxyz and others added 2 commits April 21, 2026 09:50
…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>
@biwasxyz biwasxyz requested a review from arc0btc April 21, 2026 04:12
Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three blocking/suggestion items from my previous review are addressed — approving.

What was fixed:

  1. l402Attempts counter — now a WeakMap<AxiosInstance, Map<string, number>> keyed by METHOD:URL. Endpoint A's retry limit no longer bleeds into endpoint B. clearL402AttemptCount in the success interceptor resets the per-URL budget after a clean response.

  2. invalidateL402Auth wired on cache-hit retry — the cache-hit path now catches a follow-up 402 and calls invalidateL402Auth to evict the stale entry. Exactly what was needed.

  3. Invoice amount capL402_MAX_SATS_PER_INVOICE (10k sats default, env-overridable) with light-bolt11-decoder parsing before any payInvoice call. Bounds the blast radius on a malicious BOLT-11 from an untrusted server. The bolt11→light-bolt11-decoder swap 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.

@biwasxyz biwasxyz marked this pull request as ready for review April 27, 2026 14:34
biwasxyz and others added 2 commits April 27, 2026 20:41
…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>
@whoabuddy
Copy link
Copy Markdown
Contributor

whoabuddy commented Apr 27, 2026

Does this mean users have to track 2 mnemonics now, or does it use their existing?

image

edit: after discussion, can ship with create and import now but in the future we should include the address and setup in the main wallet setup process

@whoabuddy whoabuddy requested a review from Copilot April 27, 2026 15:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 createApiClient response interceptor chain to handle WWW-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.

Comment thread src/services/x402.service.ts
Comment thread src/services/x402.service.ts Outdated
Comment thread src/tools/lightning.tools.ts
Comment thread src/utils/l402-protocol.ts Outdated
Comment thread src/utils/l402-protocol.ts
Comment thread src/services/x402.service.ts Outdated
Comment thread src/services/x402.service.ts
Comment thread src/services/lightning/spark-provider.ts Outdated
Comment thread src/services/lightning-manager.ts
- 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>
Copy link
Copy Markdown
Contributor

@whoabuddy whoabuddy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audited end-to-end against the latest commit (ea20783). Approving.

Verified in code:

  • Per-URL l402Attempts keying with reset on 200 (x402.service.ts:89, 370-378) — fixes the "endpoint A poisons endpoint B" blocker
  • invalidateL402Auth wired 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 (canonicalRequestUrl at 336-340) — prevents cross-service collisions when two clients hit the same path on different hosts
  • L402_MAX_SATS_PER_INVOICE parsed via parseSatsCap with NaN/non-finite/≤0 fallback to default (62-77) — closes the silent cap-bypass where Number(undefined) makes amountSats > NaN always false
  • Amountless invoices rejected before payment (491-497)
  • testnet rejected 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 chain
  • storeWallet initializes Spark provider BEFORE persisting keystore (lightning-manager.ts:138-171) — prevents orphaned encrypted blobs blocking retries on init failure
  • escapeRegExp applied in extractParam; single-quoted values now supported to match docstring (l402-protocol.ts:59-89)
  • lightning_claim_deposit tool exists and uses canonical SSP quote→sign→claim flow

Local verification:

  • npm run build clean
  • npm 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants