Skip to content

feat: forward trusted payment metadata upstream + onPaymentSettled host callback (0.2.1)#9

Merged
j4ys0n merged 2 commits into
mainfrom
feat/forward-payment-metadata
Jul 11, 2026
Merged

feat: forward trusted payment metadata upstream + onPaymentSettled host callback (0.2.1)#9
j4ys0n merged 2 commits into
mainfrom
feat/forward-payment-metadata

Conversation

@j4ys0n

@j4ys0n j4ys0n commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Why

missionsquad-api now does per-run x402 revenue accounting (source/priceUsd on usage metrics), but payer-address and settlement-tx reconciliation was impossible: the proxy settles in its own layer and nothing payment-related reached the upstream. This PR gives hosts a trusted, spoof-proof channel for that data.

What

1. Trusted x-x402-* upstream request headers (gated by forwardPaymentMetadata, default true):

Header Value http / http-stream-direct http-stream relay
x-x402-payment-id SDK-generated UUID (correlates with the settlement event)
x-x402-resource-id resource id, encodeURIComponent-encoded
x-x402-scheme / -network / -amount / -asset / -pay-to from the verified accepted payment requirements
x-x402-payer / x-x402-transaction facilitator settle result — (settlement follows the upstream request by design) ✓ (carried in the HMAC-signed lease token)

2. onPaymentSettled config callback — fires after every successful settlement (all kinds) with { paymentId, resourceId, kind, transaction, network, payer?, requirements, settledAt }. Fire-and-forget: a throwing/rejecting callback never affects the paid request. In-process hosts (missionsquad-api) join it to the request via x-x402-payment-id.

Security invariants

  • All nine names are registered as internal payment headers: client-supplied values are stripped unconditionally — even when a resource's forwardRequestHeaders explicitly lists them (integration-tested). An upstream can trust any x-x402-* value it sees.
  • Metadata derives only from paymentPayload.accepted (the requirements the core verified) — never from the client-controlled scheme payload (getPayer-style reads).
  • Payer identity comes only from the facilitator's settle response, never from verify-time client data.
  • Settle ordering is untouched: http/direct still settle only after the upstream accepts (status < 400); settle-failure still aborts the direct-stream upstream.
  • Header values pass a printable-ASCII guard (skip, never throw, mid-proxy); resource ids are URI-encoded.

Compatibility

  • Additive: upstreams that ignore unknown headers see no change; forwardPaymentMetadata: false restores byte-identical upstream requests.
  • Lease tokens minted before 0.2.1 relay without metadata headers (graceful absence, tested).
  • settlePayment now returns the full settle result internally (was boolean); no public API removed. Version → 0.2.1.

Tests

232 passing (13 new): per-kind header matrices + settlement-event correlation, spoofing attempt via allow-listed names, upstream→settle ordering preserved, legacy-lease relay, throwing callback isolation, forwardPaymentMetadata: false, header-guard/encoding units, config validation. yarn typecheck + yarn build clean.

Follow-up (separate repo, after publish)

missionsquad-api: pass onPaymentSettled to persist payer/tx against the usage row via x-x402-payment-id.

🤖 Generated with Claude Code

j4ys0n and others added 2 commits July 10, 2026 22:46
…ttlement

- Nine SDK-minted x-x402-* headers (payment-id, resource-id, scheme, network,
  amount, asset, pay-to, payer, transaction) injected on upstream requests,
  built exclusively from the VERIFIED accepted payment requirements; all nine
  registered as internal payment headers so client-supplied values are always
  stripped, even when a resource's forwardRequestHeaders lists them.
- x-x402-resource-id is encodeURIComponent-encoded (ids may contain hostile
  chars); other values pass a conservative printable-ASCII guard or are skipped.
- onPaymentSettled config callback fires after every successful settlement
  (all kinds) with paymentId, transaction, facilitator-reported payer, and the
  accepted requirements — fire-and-forget, never affects the paid request.
- paymentId (randomUUID) correlates the upstream headers with the settlement
  event; http/http-stream-direct omit payer/transaction headers by design
  (settlement follows the upstream request), while http-stream leases carry
  settled payment info in the HMAC-signed token so relays forward the full set.
- forwardPaymentMetadata config flag (default true) gates the headers only.
- 13 new tests (7 integration incl. spoofing + ordering + legacy-lease compat,
  5 headerPolicy unit, 1 validation unit); 232 total green. Version 0.2.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…client echo

Adversarial review found the trusted x-x402-amount/-asset/-pay-to headers and
the onPaymentSettled requirements were built from payment.paymentPayload.accepted
— the CLIENT-submitted echo, which @x402/core only deep-equals against the server
requirement for x402 v2. For v1 the core matches on scheme+network only, so a
payer could submit accepted={amount:"0", asset/payTo:attacker} and have those
forged values flow into the "trusted" headers (the charge itself stays correct —
core settles against the server requirement). Fix: source all forwarded metadata
from payment.paymentRequirements (matchingRequirements — the server's own object,
version-independent). Verified against @x402/core server/index.js:1103-1138.

Also (low): guard encodeURIComponent(resourceId) so a lone-surrogate id skips the
header instead of throwing mid-proxy (the "never throw" invariant).

Tests: F1 anti-spoof (forged v1 payload → server values win in headers + event),
F2 encode-throw safety + round-trip, F3 websocket-lease onPaymentSettled. 236 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@j4ys0n

j4ys0n commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial self-review — 1 HIGH fixed (commit 2560ef2)

Ran an adversarial pass against this diff; one finding was material and verified against @x402/core's actual source before fixing.

[HIGH] Forwarded metadata trusted the client echo, not the server requirement. toPaymentMetadata / toLeasePaymentInfo / the onPaymentSettled requirements all read payment.paymentPayload.accepted — the client-submitted object. @x402/core's findMatchingRequirements (server/index.js:846-863) only deep-equals that against the server requirement for x402Version 2; for v1 it matches on scheme+network only, so a payer could submit accepted={amount:"0", asset:…, payTo:…} and those forged values would populate the "trusted" x-x402-amount/-asset/-pay-to headers and the settlement event. (The charge itself was never at risk — core settles against the server matchingRequirements, which is exactly the object the verified result exposes as payment.paymentRequirements, server/index.js:1103-1138.) Fix: source all forwarded metadata from payment.paymentRequirements — version-independent and always the server's own object. Regression test forges a v1 payload with tampered amount/asset/payTo and asserts the server values win in both the headers and the event.

[LOW] encodeURIComponent(resourceId) could throw mid-proxy on a lone-surrogate id (operator-controlled, but validation only requires a non-empty string, and custom stores may skip validation). Now guarded — an un-encodable id skips the x-x402-resource-id header instead of 500-ing the request. Unit-tested.

[LOW] Test gaps closed: the v1 anti-spoof test above, the encode-throw/round-trip units, and a websocket-lease onPaymentSettled test (kind:"websocket").

Other verdicts held SOLID: strip-set completeness (all nine names internal, client copies dropped even when a resource allowlists them), builder ordering, HMAC lease integrity + pre-0.2.1-token graceful degradation, settle ordering unchanged, callback isolation. 236 tests green; typecheck + build clean.

@j4ys0n j4ys0n merged commit dc28f7b into main Jul 11, 2026
1 check passed
@j4ys0n j4ys0n deleted the feat/forward-payment-metadata branch July 11, 2026 05:28
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.

1 participant