Skip to content

feat(x402): support upto scheme + observability + session marker#238

Draft
MQ37 wants to merge 10 commits into
mainfrom
feat/x402-upto
Draft

feat(x402): support upto scheme + observability + session marker#238
MQ37 wants to merge 10 commits into
mainfrom
feat/x402-upto

Conversation

@MQ37
Copy link
Copy Markdown
Collaborator

@MQ37 MQ37 commented May 20, 2026

Context

apify-core PR #27039 ships x402 upto scheme support. The prod 402 payment-required response now carries both exact and upto in accepts[], but mcpc's signer only spoke exact (EIP-3009 TransferWithAuthorization).

Server-side counterpart: apify/apify-mcp-server#876.

End-to-end validation — on-chain proof, debugging journey, the CDP /verify schema gap that was resolved upstream — lives in X402_UPTO_INVESTIGATION.md on this branch.

Solution

Four commits:

  1. feat(x402): support upto scheme — adds Permit2 PermitWitnessTransferFrom signing with one-time USDC.approve(PERMIT2, MAX_UINT256) auto-grant, selectAcceptEntry(accepts, preference) (auto/upto/exact), and mcpc x402 sign --scheme / --no-approve flags. 30 Vitest unit tests cover the new code paths.
  2. feat(x402): scheme-aware debug logs and 6-decimal USD precision — verbose-mode logs print scheme=… plus key payment fields up front. USD amounts use 6 decimals (USDC atomic precision) everywhere they're logged.
  3. feat(sessions): mark x402-authenticated sessions in listings — yellow [x402] marker on x402 sessions in mcpc listings; replaces the (OAuth: …) marker when both happen to be set.
  4. feat(x402): pin scheme preference on a session via --x402-schememcpc connect --x402-scheme <auto|upto|exact> persists the preference to the session record and forwards it to the bridge → fetch middleware. mcpc restart reuses the persisted choice. Validated against the canonical X402_SCHEME_PREFERENCES array in lib/types.ts.

Worth your attention

  • Permit2 approve is one-time per token, MAX_UINT256 — first upto sign on a new wallet sends a real on-chain tx (a few cents of gas). Subsequent signs are off-chain only. Auto-grant skips when allowance is already sufficient.
  • Settlement is deferred for upto — the apify-core daemon settles 60 min after the last run finishes (or when balance drops to dust / authorization is about to expire). On-chain transfer hash arrives in a follow-up permitWitnessTransferFrom tx, not in the immediate payment-response header (which carries transaction: "").
  • Canonical scheme typeX402SchemePreference + X402_SCHEME_PREFERENCES const live in lib/types.ts. CLI validation, bridge parsing, and session persistence all import from there — no string-literal drift.

MQ37 added 4 commits May 20, 2026 14:41
Adds the `upto` scheme alongside the existing `exact` flow:

- `signUptoPayment` — EIP-712 typed-data signing over Permit2's
  PermitWitnessTransferFrom struct (witness binds {to, facilitator,
  validAfter}), with decimal-string uint256 nonce.
- One-time Permit2 ERC-20 allowance auto-approval — checks
  `USDC.allowance(wallet, PERMIT2)` and submits
  `USDC.approve(PERMIT2, MAX_UINT256)` if short. Bypassable with
  `--no-approve` for advanced/testing flows.
- `selectAcceptEntry(accepts, preference)` — picks a valid accept
  from the 402 `accepts[]` array. `auto` prefers `upto`, falls back to
  `exact`; explicit `upto` or `exact` forces one.
- `parsePaymentRequired` and `extractAcceptFromPaymentRequired`
  now use the selector instead of hard-coding `exact`.
- `mcpc x402 sign` gains `--scheme <auto|upto|exact>` and
  `--no-approve` flags.
- 30 Vitest unit tests cover the new code paths.

Spec: https://github.com/coinbase/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md

End-to-end validated against api.apify.com on Base Mainnet:
- HTTP 402 returns both schemes in accepts[]
- mcpc x402 sign --scheme upto produces a wire-correct payload
- POST with PAYMENT-SIGNATURE returns 201 + payment-response (success: true,
  transaction: '' — settlement deferred to the apify-core daemon per spec)

Known follow-up (not blocking review):
- `--scheme` preference not yet plumbed into the session-level `--x402`
  flow (only the manual `x402 sign` command honors it today).

Investigation doc in X402_UPTO_INVESTIGATION.md captures the on-chain proof,
the original CDP /verify schema gap (resolved upstream by now — prod
verifies upto fine), and the debugging history.
When verbose mode is on, the x402 signer now announces the scheme +
key payment fields up front:

  [x402-signer] Signing x402 payment: scheme=upto network=eip155:8453
    amount=1000000 asset=0x... payTo=0x... facilitator=0x...

The two existing 'payment signed' summaries in the fetch middleware now
include `scheme=` and the bridge retry log uses the same precision —
all USD amounts in debug logs are now 6 decimals (USDC atomic precision)
instead of 4.
Sessions with auto-payment enabled show a yellow [x402] marker in
`mcpc` listings, matching the visual style of the OAuth and proxy
markers. OAuth and x402 are mutually exclusive auth mechanisms, so
the marker replaces the (OAuth: ...) one when both happen to be set
on the session record.
`mcpc connect --x402-scheme <auto|upto|exact>` plumbs the scheme
preference end-to-end:

- CLI validates against `X402_SCHEME_PREFERENCES` (canonical source in
  `lib/types.ts`) and rejects `--x402-scheme` without `--x402`.
- Persisted on `SessionData.x402Scheme` so `mcpc restart` reuses the
  choice.
- Forwarded to the bridge as `--x402-scheme <value>` and passed to
  `createX402FetchMiddleware({ schemePreference })`, which already
  honored the option.

Default (when not specified) is `auto` — prefer upto, fall back to
exact — same as the existing `mcpc x402 sign` default.
@TateLyman
Copy link
Copy Markdown

Ran a focused pass on the --x402-scheme plumbing because this PR introduces auto|upto|exact as a user-visible safety lever.

One launch-risk edge I would fix before merge:

--x402-scheme exact is only honored on the HTTP 402 fallback path. The proactive tool metadata path still signs whatever flat _meta.x402 advertises, and the MCP tool-result retry path still selects with hard-coded auto.

Relevant paths:

  • createX402FetchMiddleware() receives schemePreference, but calls getOrSignPayment(init, wallet, getToolByName, paymentCache) without passing it.
  • getOrSignPayment() then builds an accept directly from the flat tool _meta.x402 fields. The companion server PR intentionally prefers upto for those flat fields, so an mcpc connect --x402-scheme exact ... session can still sign upto proactively before it ever reaches the fallback that respects exact.
  • extractAcceptFromPaymentRequired() also calls selectAcceptEntry(..., 'auto'), and BridgeProcess.handlePaymentRequiredRetry() uses that helper without passing this.options.x402Scheme.

Why it matters: upto has the one-time Permit2 approval path and different settlement timing. If a user pins exact specifically to avoid Permit2 approval/deferred settlement, the first paid tools/call can still take the upto branch as long as the server advertises flat _meta.x402 from the preferred accept. That turns the scheme preference into a fallback-only hint instead of a hard session policy.

Suggested patch shape:

  • pass schemePreference into getOrSignPayment();
  • when tool metadata includes accepts[], call selectAcceptEntry(accepts, schemePreference ?? 'auto') instead of trusting only the flat fields;
  • make extractAcceptFromPaymentRequired(data, schemePreference = 'auto') accept the preference and use it from handlePaymentRequiredRetry();
  • add a regression test where metadata has both upto and exact, schemePreference: 'exact', and the signed payload ends up accepted.scheme === 'exact' with no Permit2 allowance check.

Scope: code review only. I did not run a paid Apify call, send payment headers, sign wallet payloads, or attempt settlement.

…etry paths

`--x402-scheme` previously only kicked in on the HTTP-402 fallback. Two other
signing paths defaulted to `auto` and signed whatever the server preferred:

1. The proactive-sign path (`getOrSignPayment`) read only the flat
   `_meta.x402.{scheme,…}` fields, missing the new `accepts[]` advertising
   and ignoring `schemePreference` entirely.
2. The tool-result retry path (`extractAcceptFromPaymentRequired` called from
   `BridgeProcess.handlePaymentRequiredRetry`) hard-coded `selectAcceptEntry(..., 'auto')`.

Both now honor the configured preference end-to-end:

- `createX402FetchMiddleware` passes `schemePreference` into `getOrSignPayment`.
- New `selectAcceptFromToolMeta` helper consumes `_meta.x402.accepts[]` when
  present (post apify-mcp-server #876), falling back to flat fields only when
  the preference matches the flat scheme (or preference is `auto`).
- `extractAcceptFromPaymentRequired` takes a `schemePreference` parameter and
  the bridge passes `this.options.x402Scheme` through to it.

When the proactive path can't honor the preference (e.g. pre-#876 server with
flat-only `_meta.x402.scheme=upto` and `--x402-scheme exact`), it skips signing
and lets the 402 fallback handle it \u2014 the 402 response is the authoritative
source of `accepts[]` regardless of what the server advertises proactively.

Refs #238 review comment from @TateLyman.

New `fetch-middleware.test.ts` covers:
- proactive sign with accepts=[exact, upto] and schemePreference=exact \u2192 signs exact
- proactive sign with accepts=[exact, upto] and schemePreference=upto \u2192 signs upto
- proactive sign with accepts=[upto] and schemePreference=exact \u2192 skips
- proactive sign with legacy flat-only upto and schemePreference=exact \u2192 skips
- proactive sign with default auto preference \u2192 prefers upto
- extractAcceptFromPaymentRequired with each preference value
@MQ37
Copy link
Copy Markdown
Collaborator Author

MQ37 commented May 21, 2026

Good catch — fixed in e374380.

The schemePreference was indeed leaky to the HTTP-402 fallback only. Now plumbed end-to-end:

  • createX402FetchMiddleware passes schemePreference into getOrSignPayment.
  • New selectAcceptFromToolMeta helper consumes _meta.x402.accepts[] when present, falling back to flat fields only when the preference matches the flat scheme (or preference is auto).
  • extractAcceptFromPaymentRequired takes schemePreference and the bridge passes this.options.x402Scheme through to it.

When the proactive path can't honor the preference (e.g. pre-#876 server with flat-only _meta.x402.scheme=upto and --x402-scheme exact), it skips signing and defers to the 402 fallback — the 402 response is the authoritative source regardless of proactive advertising.

New fetch-middleware.test.ts adds the regression coverage you suggested:

  • proactive sign with accepts=[exact, upto] and schemePreference=exact → signs exact (no Permit2 calls)
  • proactive sign with accepts=[upto] only and schemePreference=exact → skips, defers to 402
  • legacy flat-only upto advertising with schemePreference=exact → skips, defers to 402
  • extractAcceptFromPaymentRequired honors each 'auto' | 'upto' | 'exact' value

…ADME

- Explains `exact` (EIP-3009) vs `upto` (Permit2) scheme semantics.
- Documents `--x402-scheme <auto|upto|exact>` session configuration and persistence.
- Adds `--scheme` and `--no-approve` options to `mcpc x402 sign` table.
- Cleans up type duplication of `SchemePreference` by aliasing `X402SchemePreference` imported from types.ts.
Comment thread src/bridge/index.ts Outdated
proxyConfig?: ProxyConfig; // Proxy server configuration
mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only)
x402?: boolean; // Enable x402 auto-payment
x402Scheme?: X402SchemePreference; // x402 scheme preference (only with x402: true)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we even need x402 if we have x402Scheme ? I'd keep just one property to simplify the code - if x402Scheme is null, then it should mean x402 is disabled

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

True, what about keeping only the --x402 and adding these options --x402 auto/upto/exact and if user inputs only --x402 without any input it defaults to auto?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah I thought of that, but it's not fully unique - e.g. if you have some local server called "auto" and run mcpc connect --x402 auto, the outcome might be something else than you like. But maybe it's such a fringe case that we can live with that and basically consider "auto|upto|exact" as reserved words

When the bridge process crashes in the background, `restartBridge` reads the saved
session record from `sessions.json` and spawns a new bridge. Previously, it only
forwarded `session.x402: true` to `bridgeOptions` but forgot to plumb
`session.x402Scheme` (the pinned preference). This caused the restarted bridge
to revert to the default `auto` scheme preference, violating the pinned session policy.

Now correctly forwards `session.x402Scheme` on automatic crash restarts.

Refs Rule 25: Plumb user-visible configurations end-to-end to avoid leaky parameter gaps.
Copy link
Copy Markdown
Member

@jancurn jancurn left a comment

Choose a reason for hiding this comment

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

Make sure that if one uses mcpc connect --x402-schema xxx we implicitly consider as if they added --x402 too, to make it less error prone

MQ37 added 3 commits May 21, 2026 11:30
…p dead conditional in upto signer

Two small leaky-parameter findings from a second-pass review (Rule 25 in code-quality skill):

1. `BulkConnectOptions` did not declare `x402Scheme`. The value was passed
   implicitly via `{ ...globalOpts }` spread to `connectAllFromConfig` /
   `connectAllFromStandardConfigs`, so it survived at runtime, but the typed
   parameter view dropped it \u2014 fragile to any future refactor that destructures
   the options object instead of re-spreading it. Declare the field and add
   explicit spreads at the two CLI call sites so the contract is type-enforced.

2. `signUptoPayment` built the accepted.extra block with
   `...(facilitatorAddress ? { facilitatorAddress } : {})`, but the function
   throws earlier when `facilitatorAddress` is empty \u2014 the false branch was
   unreachable. Inline the field directly with a one-line invariant comment.
Single user-visible flag instead of two. Boolean+enum data model becomes
one nullable string field. Eliminates the 'scheme set without x402: true'
class of bugs and shrinks Rule 25 surface area in half.

CLI:

- `--x402 [scheme]` (optional value): bare `--x402` defaults to 'auto';
  `--x402 upto` / `--x402 exact` pins the preference.
- `--x402-scheme` removed.
- Commander's greedy parser eats the next token as the value; CLI validates
  it must be in {auto,upto,exact} and throws otherwise so a URL/session can't
  slip through silently. Help text documents `--x402=<scheme>` as the
  unambiguous form when followed by positional args.

Data model:

- `SessionData.x402: X402SchemePreference | undefined` (presence = enabled).
- Legacy fields `x402: boolean` + `x402Scheme` are normalised on session read
  by `normaliseLegacyX402` and rewritten on the next save. Read once, then
  the on-disk format converges to the new shape.

Plumbing tightened (one parameter instead of two):

- `BridgeOptions.x402`, `StartBridgeOptions.x402`, `HandlerOptions.x402`,
  `BulkConnectOptions.x402` all become `X402SchemePreference?`.
- Bridge IPC arg is now `--x402 <scheme>` (was `--x402` + `--x402-scheme`).
- `createX402FetchMiddleware` and `extractAcceptFromPaymentRequired` receive
  the value directly from `this.options.x402` \u2014 no second field to keep in sync.

Tests:

- 7 new unit tests for `normaliseLegacyX402` covering legacy true/false,
  legacy true+scheme, idempotency, defensive drop on invalid strings, stale
  `x402Scheme` sidecar without parent flag.
- Stub-resistant per Rule 22: 5 of 7 fail when the migrator is no-op'd.
- Full suite: 627 tests pass (+7 from previous 620).

Docs:

- README auth-flags table updated; 'Using x402 with MCP servers' subsection
  rewritten to show the new examples and document the equals form.
- CHANGELOG Unreleased entry rewritten.

Breaking: `--x402-scheme` was added in this same Unreleased cycle and never
shipped \u2014 no released-API users to migrate. The on-disk legacy shape is
auto-migrated transparently on read.
…sage

The four validation throws in `getOptionsFromCommand` (`--timeout`, `--x402`,
`--schema-mode`, `--max-chars`) used plain `new Error(...)`, which bubbled up
as an uncaught exception and dumped a full Node stack trace on stderr.

Swap them to `ClientError` so the top-level handler formats them as a
one-line "Error: ..." message and exits with code 1, matching every other
user-visible validation error in the codebase.
@MQ37
Copy link
Copy Markdown
Collaborator Author

MQ37 commented May 21, 2026

@jancurn I unified the CLI args and right now there is only the --x402 [auto/upto/exact] with optional schema option - by default it uses auto that prefers the upto

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