feat(x402): support upto scheme + observability + session marker#238
feat(x402): support upto scheme + observability + session marker#238MQ37 wants to merge 10 commits into
Conversation
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.
|
Ran a focused pass on the One launch-risk edge I would fix before merge:
Relevant paths:
Why it matters: Suggested patch shape:
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
|
Good catch — fixed in e374380. The
When the proactive path can't honor the preference (e.g. pre-#876 server with flat-only New
|
…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.
| 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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
jancurn
left a comment
There was a problem hiding this comment.
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
…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.
|
@jancurn I unified the CLI args and right now there is only the |
Context
apify-core PR #27039 ships x402
uptoscheme support. The prod 402payment-requiredresponse now carries bothexactanduptoinaccepts[], but mcpc's signer only spokeexact(EIP-3009TransferWithAuthorization).Server-side counterpart: apify/apify-mcp-server#876.
End-to-end validation — on-chain proof, debugging journey, the CDP
/verifyschema gap that was resolved upstream — lives inX402_UPTO_INVESTIGATION.mdon this branch.Solution
Four commits:
feat(x402): support upto scheme— adds Permit2PermitWitnessTransferFromsigning with one-timeUSDC.approve(PERMIT2, MAX_UINT256)auto-grant,selectAcceptEntry(accepts, preference)(auto/upto/exact), andmcpc x402 sign --scheme/--no-approveflags. 30 Vitest unit tests cover the new code paths.feat(x402): scheme-aware debug logs and 6-decimal USD precision— verbose-mode logs printscheme=…plus key payment fields up front. USD amounts use 6 decimals (USDC atomic precision) everywhere they're logged.feat(sessions): mark x402-authenticated sessions in listings— yellow[x402]marker on x402 sessions inmcpclistings; replaces the(OAuth: …)marker when both happen to be set.feat(x402): pin scheme preference on a session via --x402-scheme—mcpc connect --x402-scheme <auto|upto|exact>persists the preference to the session record and forwards it to the bridge → fetch middleware.mcpc restartreuses the persisted choice. Validated against the canonicalX402_SCHEME_PREFERENCESarray inlib/types.ts.Worth your attention
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-uppermitWitnessTransferFromtx, not in the immediatepayment-responseheader (which carriestransaction: "").X402SchemePreference+X402_SCHEME_PREFERENCESconst live inlib/types.ts. CLI validation, bridge parsing, and session persistence all import from there — no string-literal drift.