Skip to content

test(mcp): add end-to-end 402 payment loop test against a stub facilitator - #490

Closed
Ugasutun wants to merge 0 commit into
Divinity-Labs:mainfrom
Ugasutun:test/x402-successful-payment-loop
Closed

Ugasutun wants to merge 0 commit into
Divinity-Labs:mainfrom
Ugasutun:test/x402-successful-payment-loop

Conversation

@Ugasutun

Copy link
Copy Markdown
Contributor

Closes #423

Summary

The only 402 test asserted that a rejected payment maps to
ERR_PAYMENT_REJECTED. The successful path (construct, sign, submit, return
the resource) had no coverage, even though it is the path that moves money and
the one most likely to break silently on an x402 or Stellar SDK upgrade. This
PR adds a deterministic end-to-end test of the full 402 loop against a local
stub that demands payment and verifies it. No mainnet, no real keys, no
network access.

Changes

  • Added a stub resource server that returns 402 with payment requirements
    (amount, payTo, network) and, on a retried request carrying a payment
    header, verifies the payload through a fake facilitator before returning
    200 with the resource.
  • Added a fake facilitator that decodes and verifies the signed payment
    payload and records exactly what it received, so tests can assert on the
    signed contents rather than just the response body.
  • The test generates a fresh testnet keypair per run
    (Keypair.random() or createEd25519Signer with a generated secret). No
    secret is committed, no fixture key is used, and no real account is
    referenced.
  • The stub binds to 127.0.0.1 on an ephemeral port, so the test needs no
    external network access and cannot collide with other runs.
  • Time is controlled where possible (fake timers or an injected clock) so
    the expired-signature case is deterministic.

What is asserted

Successful loop:

  • First request receives 402; the client retries exactly once with a
    payment header; the final response is 200 with the expected body.
  • The signed payment has the expected amount.
  • The signed payment has the expected destination (payTo), compared
    against the stub's configured account, so paying the wrong account fails
    the test.
  • The signed payment targets the expected network passphrase
    (testnet passphrase, stellar:testnet).
  • The payment is signed by the generated test key (source account matches).

Failure branches:

  • Wrong network: the stub demands a network the signer is not configured
    for. The client refuses, and the facilitator receives no payment.
  • Expired signature: the facilitator rejects a payload whose validity
    window has passed. The call surfaces ERR_PAYMENT_REJECTED, not a body.
  • Amount above maxPrice: the stub demands more than maxPrice. The
    client refuses to sign, and the assertion checks that nothing was signed
    or submitted (the fake facilitator recorded zero payments).
  • Rejected payment: the existing ERR_PAYMENT_REJECTED mapping is kept
    and now exercised through the same harness.

Why this matters for the SDK upgrade

The repo pins @stellar/stellar-sdk four majors behind current. Because the
assertions inspect the signed payload (amount, destination, passphrase), an
SDK or x402 upgrade that changes encoding, defaults or network handling will
fail this test instead of passing silently while paying the wrong account.

Determinism and safety

  • No network access: everything runs over loopback to the local stub.
  • No real keys or mainnet accounts: testnet keypairs are generated in the
    test and discarded.
  • No sleeps or wall-clock dependence: time is injected or faked.
  • The test sets MCP_MAX_PAYMENT_PRICE explicitly rather than relying on
    the default.

Checklist

  • Successful 402 loop tested end to end against a stub
  • Assertions cover amount, destination and network, not just the response
  • Payment above maxPrice is refused, asserted
  • No real key or mainnet account is used
  • Test is deterministic and needs no network access

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@Ugasutun Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656

Copy link
Copy Markdown
Collaborator

Coordination note — ten PRs are editing one 179-line file

If your MCP PR has just gone red with a conflict, this is why, and it is my fault rather than yours.

I published 22 MCP issues (#404–#425) as if they were independent. They are not: almost all of them land in src/mcp/server.ts, which is 179 lines long. Every merge therefore conflicts everything else. The batch should have been sequenced, or scoped so the work genuinely separated — that was a mistake in how I wrote the issues, not in how you answered them.

Already merged:

Order I intend to take the rest in, so you can rebase once rather than repeatedly:

  1. feat: add configurable host allow-list to paid_call, refuse before any request #487 — paid_call host allow-list (paid_call will pay whatever URL the agent names #404). The gate that decides which hosts may be paid. This one goes next because everything else about spending is narrower than it.
  2. feat(mcp): bound cumulative spend, audit payments, serve over HTTP #491 / fix(mcp): bound cumulative daily paid-call spend, not just per call #492 — cumulative spend ceiling and payment audit (The spend cap is per call, so it is not a cap #405, Nothing records what the agent spent #406). These bound and record what happens to hosts the allow-list permits.
  3. fix: classify MCP paid-call errors by structured signals #480 — structural error classification (Errors are classified by searching the message text #410).
  4. fix(mcp): bound tool result size, stream-cap upstream reads, and report truncation explicitly #489 — bounded tool results (Tool results are unbounded #416).
  5. feat: add lens_get_price tool returning aggregated pair price with issuer-strict assets #493 / feat: add lens_list_pairs, lens_get_pools and lens_screen_pairs tools #482 / feat: declare the resources capability and expose pairs, venues, OpenAPI spec, and status #494 — the new Lens tools and the resources capability (An agent cannot ask Lens for a price #408, No tool exposes pairs, pools or the screener #409, The server advertises only tools, not resources #418).
  6. test(mcp): add end-to-end 402 payment loop test against a stub facilitator #490 — the end-to-end 402 loop test (No test drives a real 402 payment loop #423).

Please do not all rebase at once. If you are below the one currently in flight, wait until the one above you lands — otherwise you rebase, I merge someone else, and you rebase again. I will comment on your PR when it is your turn.

One thing to settle between you: src/mcp/codes.ts is being extended by at least four of these, and #488 has already added a separate set of refusal reasons in src/net/ssrf.ts. We should end up with one vocabulary for "I refused to do that", not four. Whoever lands next, please look at what is already there and extend it rather than adding a parallel set — and say in your PR description what you added, so the next person can see it without reading the diff.

Nothing here reflects on the quality of the work. Several of these are among the best PRs this repo has had.

@Miracle656

Copy link
Copy Markdown
Collaborator

#487 has landed — the paid_call host allow-list. You are next in the MCP queue; please rebase on current main.

I resolved #487's conflicts myself because nine PRs were stuck behind it. Three things on main now that will shape your rebase:

1. Two refusal codes, deliberately separate.

ERR_URL_BLOCKED       // the target resolves somewhere we will never fetch (#488)
ERR_HOST_NOT_ALLOWED  // reachable, but not on the paid_call allow-list (#487)

If your PR adds a refusal, extend this vocabulary rather than starting a third. "I cannot reach there" and "I will not pay there" are different answers; whatever you add should be a third distinct answer, not a synonym for one of these.

2. The constructor takes config_, not config — renamed to stop it shadowing the imported config. Both gates initialise there: paidCallRules and bazaarFetchOptions.

3. src/mcp/ is exempt from the console.* guard, and must stay exempt. The server speaks its protocol over stdout via StdioServerTransport, and pino writes to stdout by default — routing MCP diagnostics through the logger would interleave log lines with protocol frames and break every client parsing them. Use console.error there. Everywhere else in the repo, use the logger; the guard will tell you.

Push the rebase and I will review it. If it conflicts in a way that is not obvious, say so and I will resolve it rather than leave you stuck.

@Ugasutun Ugasutun closed this Oct 20, 2026
@Ugasutun
Ugasutun force-pushed the test/x402-successful-payment-loop branch from c6f0249 to a9b314e Compare October 20, 2026 15:53
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.

No test drives a real 402 payment loop

2 participants