From f67ba314e1df328762b6a43872b2178cba1ad0c7 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Sat, 4 Apr 2026 01:27:33 -0700 Subject: [PATCH 1/6] spec: add operation-binding extension draft --- specs/extensions/operation-binding.md | 620 ++++++++++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 specs/extensions/operation-binding.md diff --git a/specs/extensions/operation-binding.md b/specs/extensions/operation-binding.md new file mode 100644 index 0000000000..0b76cc560e --- /dev/null +++ b/specs/extensions/operation-binding.md @@ -0,0 +1,620 @@ +# Extension: `operation-binding` + +## Summary + +The `operation-binding` extension defines a companion proof that binds a successful x402 payment to one exact validated operation. + +In the first version of this extension, the bound operation is an HTTP request whose validated inputs are representable as JSON per RFC 8785. + +This extension is intentionally separate from: + +- `offer-and-receipt`, which proves what the resource server offered and that it returned a successful response +- facilitator-side settlement attestations such as the proposal in [#1802](https://github.com/x402-foundation/x402/issues/1802), which prove how a payment was settled + +The goal here is narrower: + +> prove that payment was accepted for this exact validated operation, not just for this route or this settlement transaction. + +--- + +## Goals + +- Bind a signed receipt to one exact validated HTTP operation. +- Make the binding deterministic across SDKs and frameworks. +- Define a strict and reproducible `operationDigest`. +- Compose cleanly with `offer-and-receipt` and facilitator settlement attestations. +- Leave room for future tooling such as OpenAPI-generated paid proxies. + +## Non-Goals + +- Replacing `offer-and-receipt`. +- Replacing facilitator-side settlement proofs or the attestation direction discussed in [#1802](https://github.com/x402-foundation/x402/issues/1802). +- Defining client-side budget allocation or wallet policy. +- Defining persistent Sign-In-With-X storage or proofs. +- Covering binary, multipart, streaming, or non-JSON request bodies in version `1`. +- Defining MCP operation binding in version `1`. + +--- + +## Threat Model + +This extension is designed to reduce the following classes of error or abuse: + +- **Cross-operation replay**: a receipt for one validated operation is presented as proof for a different operation. +- **Parameter substitution**: a receipt for `/users/123` is reused for `/users/456`, or a receipt for `amount=1` is reused for `amount=100`. +- **Canonicalization drift**: two implementations hash logically identical requests differently because they disagree about ordering, whitespace, or omission rules. +- **Ambiguous retry semantics**: repeated payment submissions for the same idempotency key drift to different bound operations. + +This extension does **not** by itself prove settlement details such as fee, amount transferred on-chain, or transaction inclusion. Those concerns belong to the payment scheme itself, `offer-and-receipt`, or a facilitator-side settlement attestation. + +--- + +## Scope and Status + +This is a companion extension proposal. + +- The first version covers only `transport = "http"`. +- The first version covers only operations whose bound inputs can be represented as I-JSON and canonicalized with RFC 8785. +- The first version defines a resource-server-signed operation receipt. + +Servers SHOULD treat this extension as opt-in per route. + +--- + +## `PaymentRequired` + +A resource server advertises operation binding support by including the `operation-binding` extension in the `extensions` object of the `402 Payment Required` response. + +The extension follows the standard v2 pattern: + +- `info`: binding policy for this request +- `schema`: JSON Schema for `info` + +### `info` Fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `transport` | string | Yes | Currently MUST be `"http"` | +| `resourceUrl` | string | Yes | Absolute resource URL for this request, without query string or fragment | +| `method` | string | Yes | Uppercase HTTP method | +| `pathTemplate` | string | Yes | Canonical route template using `:param` syntax | +| `operationId` | string | Yes | Stable server-defined operation identifier | +| `policyVersion` | string | Yes | Stable version string for the binding policy and validated input contract | +| `canonicalization` | string | Yes | Currently MUST be `"rfc8785-jcs"` | +| `digestAlgorithm` | string | Yes | Currently MUST be `"sha-256"` | +| `bindPathParams` | boolean | Yes | Whether validated path params participate in the digest | +| `bindQuery` | boolean | Yes | Whether validated query params participate in the digest | +| `bindBody` | boolean | Yes | Whether the validated body participates in the digest | + +### Requirements + +- `resourceUrl` MUST be the absolute request URL without query string or fragment. +- `method` MUST be uppercase. +- `pathTemplate` MUST use the same `:param` syntax described for `routeTemplate` in the `bazaar` extension. +- `operationId` MUST be stable for the semantic operation being paid for. +- `policyVersion` MUST change when the server changes validated input semantics in a way that would change the meaning of existing receipts. +- Servers MUST NOT advertise `bindBody: true` unless the validated body can be represented as I-JSON. + +### Example + +```json +{ + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": "https://api.example.com/weather/SF", + "description": "Weather lookup", + "mimeType": "application/json" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "10000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "maxTimeoutSeconds": 60 + } + ], + "extensions": { + "operation-binding": { + "info": { + "transport": "http", + "resourceUrl": "https://api.example.com/weather/SF", + "method": "GET", + "pathTemplate": "/weather/:city", + "operationId": "weather.getCurrent", + "policyVersion": "2026-04-04", + "canonicalization": "rfc8785-jcs", + "digestAlgorithm": "sha-256", + "bindPathParams": true, + "bindQuery": true, + "bindBody": false + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "transport": { "type": "string", "const": "http" }, + "resourceUrl": { "type": "string", "format": "uri" }, + "method": { "type": "string" }, + "pathTemplate": { "type": "string" }, + "operationId": { "type": "string" }, + "policyVersion": { "type": "string" }, + "canonicalization": { "type": "string", "const": "rfc8785-jcs" }, + "digestAlgorithm": { "type": "string", "const": "sha-256" }, + "bindPathParams": { "type": "boolean" }, + "bindQuery": { "type": "boolean" }, + "bindBody": { "type": "boolean" } + }, + "required": [ + "transport", + "resourceUrl", + "method", + "pathTemplate", + "operationId", + "policyVersion", + "canonicalization", + "digestAlgorithm", + "bindPathParams", + "bindQuery", + "bindBody" + ], + "additionalProperties": false + } + } + } +} +``` + +--- + +## `PaymentPayload` + +Clients SHOULD echo the `operation-binding` extension from `PaymentRequired` into the `PaymentPayload` unchanged. + +Servers MAY reject the payment with `400` or `409` if the echoed extension differs from what the server advertised for that request. + +This echo step is not the source of truth for the digest. The source of truth is always the server's own validated request state at execution time. + +--- + +## Exact `operationDigest` Inputs + +After request validation, coercion, and default application, the server MUST construct the following logical input object: + +```json +{ + "version": 1, + "transport": "http", + "resourceUrl": "https://api.example.com/weather/SF", + "method": "GET", + "pathTemplate": "/weather/:city", + "operationId": "weather.getCurrent", + "policyVersion": "2026-04-04", + "pathParams": { + "city": "SF" + }, + "query": { + "units": "metric" + }, + "body": null +} +``` + +### Field Semantics + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `version` | number | Yes | MUST be `1` | +| `transport` | string | Yes | MUST be `"http"` | +| `resourceUrl` | string | Yes | Absolute request URL without query string or fragment | +| `method` | string | Yes | Uppercase HTTP method | +| `pathTemplate` | string | Yes | Canonical route template using `:param` syntax | +| `operationId` | string | Yes | Stable operation identifier | +| `policyVersion` | string | Yes | Server-defined policy version | +| `pathParams` | object or `null` | Yes | Validated path params if `bindPathParams = true`, otherwise `null` | +| `query` | object or `null` | Yes | Validated query object if `bindQuery = true`, otherwise `null` | +| `body` | any JSON value or `null` | Yes | Validated body if `bindBody = true`, otherwise `null` | + +### Input Rules + +- The server MUST compute the logical input object from the validated request representation, not from raw bytes. +- `pathParams` and `query` MUST be JSON objects when present. +- `body` MAY be any JSON value when present, including an object, array, string, number, boolean, or `null`. +- If a component is not bound, the server MUST set the corresponding field to `null` even if the raw request contained that component. +- If a component is bound but absent after validation, the server MUST set the corresponding field to `null`. +- Fragment identifiers MUST NOT be included. +- Query parameters MUST be represented only through the validated `query` field, never inside `resourceUrl`. +- If a validated value is not representable as I-JSON, the server MUST NOT use this extension for that request. + +--- + +## Exact Canonicalization Rules + +Servers and verifiers MUST compute `operationDigest` as follows: + +1. Construct the logical input object defined above. +2. Serialize that object using the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) JSON Canonicalization Scheme. +3. Encode the canonical JSON string as UTF-8 bytes. +4. Compute SHA-256 over those bytes. +5. Encode the resulting digest as lowercase hex without separators. + +### Normative Requirements + +- Implementations MUST use RFC 8785 exactly, not a "JCS-like" variant. +- Implementations MUST NOT preserve input whitespace, object insertion order, or source formatting. +- Implementations MUST use the validated JSON representation after schema coercion and defaulting. +- Implementations MUST NOT normalize Unicode beyond what RFC 8785 requires. +- Implementations MUST reject duplicate object member names before canonicalization. +- Implementations MUST use lowercase hex for the final digest string. + +### Reference Formula + +```text +operationDigest = hex(sha256(utf8(rfc8785(logicalInputObject)))) +``` + +--- + +## Operation Receipt + +On success, the server MAY include a signed operation receipt in the `SettlementResponse`. + +### Placement + +The receipt is returned at: + +```text +extensions["operation-binding"].info.receipt +``` + +### Signed Envelope + +This extension reuses the same signed envelope structure as `offer-and-receipt`: + +- `format` +- `payload` for EIP-712 +- `signature` + +Supported formats are: + +- `eip712` +- `jws` + +Signer authorization follows the same model as `offer-and-receipt`: the verifier MUST confirm that the signer is authorized to act for the service identified by `resourceUrl`. + +### Receipt Payload Fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `version` | number | Yes | Receipt payload schema version, currently `1` | +| `network` | string | Yes | CAIP-2 network identifier for the payment | +| `transport` | string | Yes | Currently `"http"` | +| `resourceUrl` | string | Yes | Absolute request URL without query string or fragment | +| `method` | string | Yes | Uppercase HTTP method | +| `pathTemplate` | string | Yes | Canonical route template using `:param` syntax | +| `operationId` | string | Yes | Stable operation identifier | +| `policyVersion` | string | Yes | Server-defined policy version | +| `canonicalization` | string | Yes | Currently `"rfc8785-jcs"` | +| `digestAlgorithm` | string | Yes | Currently `"sha-256"` | +| `bindPathParams` | boolean | Yes | Whether path params were bound | +| `bindQuery` | boolean | Yes | Whether query params were bound | +| `bindBody` | boolean | Yes | Whether body was bound | +| `operationDigest` | string | Yes | Lowercase hex digest of the canonicalized logical input object | +| `payer` | string | Yes | Payer identifier | +| `issuedAt` | number | Yes | Unix timestamp in seconds | + +### EIP-712 Domain + +All EIP-712 signatures in this extension use: + +```javascript +{ + name: "x402 operation receipt", + version: "1", + chainId: 1 +} +``` + +### EIP-712 Types + +```javascript +{ + "primaryType": "OperationReceipt", + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" } + ], + "OperationReceipt": [ + { "name": "version", "type": "uint256" }, + { "name": "network", "type": "string" }, + { "name": "transport", "type": "string" }, + { "name": "resourceUrl", "type": "string" }, + { "name": "method", "type": "string" }, + { "name": "pathTemplate", "type": "string" }, + { "name": "operationId", "type": "string" }, + { "name": "policyVersion", "type": "string" }, + { "name": "canonicalization", "type": "string" }, + { "name": "digestAlgorithm", "type": "string" }, + { "name": "bindPathParams", "type": "bool" }, + { "name": "bindQuery", "type": "bool" }, + { "name": "bindBody", "type": "bool" }, + { "name": "operationDigest", "type": "string" }, + { "name": "payer", "type": "string" }, + { "name": "issuedAt", "type": "uint256" } + ] + } +} +``` + +### Example Receipt + +```json +{ + "format": "eip712", + "payload": { + "version": 1, + "network": "eip155:8453", + "transport": "http", + "resourceUrl": "https://api.example.com/weather/SF", + "method": "GET", + "pathTemplate": "/weather/:city", + "operationId": "weather.getCurrent", + "policyVersion": "2026-04-04", + "canonicalization": "rfc8785-jcs", + "digestAlgorithm": "sha-256", + "bindPathParams": true, + "bindQuery": true, + "bindBody": false, + "operationDigest": "8db6ee2cc9d1f19dbf9d502f92c0edcf3f48c8a7f8fd5378d8cebd1d7955db6b", + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66", + "issuedAt": 1775289900 + }, + "signature": "0x1234567890abcdef..." +} +``` + +--- + +## Verifier Behavior + +### Within the x402 Request Lifecycle + +When a client or downstream component verifies an operation receipt for a request it observed directly, it SHOULD: + +1. Verify the signed envelope using the rules for the selected format. +2. Confirm that the signer is authorized for `resourceUrl`. +3. Confirm that `canonicalization` and `digestAlgorithm` are supported. +4. Reconstruct the logical input object from the validated request state using the receipt payload fields. +5. Recompute `operationDigest`. +6. Compare the recomputed digest to the signed `operationDigest`. +7. Confirm `issuedAt` is within verifier policy. + +Verification MUST fail if: + +- the signature is invalid +- the signer is not authorized +- canonicalization rules differ from this specification +- the recomputed digest does not match + +### Outside the x402 Request Lifecycle + +An external verifier that does not possess the validated request inputs MAY still verify: + +- the receipt signature +- signer authorization +- receipt metadata + +However, it cannot fully verify the `operationDigest` binding unless it also has the exact validated inputs required to reconstruct the logical input object. + +--- + +## Interaction with `payment-identifier` + +`operation-binding` and `payment-identifier` solve different problems: + +- `payment-identifier` provides idempotency and retry safety +- `operation-binding` provides semantic proof of what the payment authorized + +When both extensions are present: + +- clients SHOULD reuse the same `payment-identifier` when retrying the same operation +- servers SHOULD cache or reproduce the same operation receipt for retries that produce the same `payment-identifier` and the same `operationDigest` +- servers SHOULD reject reuse of the same `payment-identifier` with a different `operationDigest` + +This means the pair `("payment-identifier".id, operationDigest)` becomes the practical idempotency contract for bound operations. + +This extension does not require `payment-identifier`, but deployments that care about safe retries SHOULD use both together. + +--- + +## Composition with `offer-and-receipt` and `#1802` + +This extension is intended to compose with, not replace, adjacent receipt work: + +- `offer-and-receipt` proves what the resource server offered and that it returned a successful response +- `operation-binding` proves which validated operation that successful response corresponds to +- facilitator-side attestation work such as [#1802](https://github.com/x402-foundation/x402/issues/1802) proves settlement details such as transaction hash, amount, and fee + +These are distinct layers: + +| Layer | Signed by | Main question answered | +| --- | --- | --- | +| `offer-and-receipt` | Resource server | "What was offered, and did the server declare success?" | +| `operation-binding` | Resource server | "What exact validated operation did that success correspond to?" | +| Facilitator attestation (`#1802`) | Facilitator | "How was the payment settled?" | + +An implementation MAY include both `operation-binding` and a facilitator-side settlement attestation in the same success response. + +Future work MAY define a higher-level composed business receipt, but that is out of scope for this extension. + +--- + +## HTTP Examples + +### Example A: `GET /weather/:city` + +#### Request + +```http +GET /weather/SF?units=metric HTTP/1.1 +Host: api.example.com +X-PAYMENT: +``` + +#### Validated Request State + +```json +{ + "pathParams": { + "city": "SF" + }, + "query": { + "units": "metric" + }, + "body": null +} +``` + +#### Logical Input Object + +```json +{ + "version": 1, + "transport": "http", + "resourceUrl": "https://api.example.com/weather/SF", + "method": "GET", + "pathTemplate": "/weather/:city", + "operationId": "weather.getCurrent", + "policyVersion": "2026-04-04", + "pathParams": { + "city": "SF" + }, + "query": { + "units": "metric" + }, + "body": null +} +``` + +#### Success Response + +```json +{ + "success": true, + "network": "eip155:8453", + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66", + "extensions": { + "operation-binding": { + "info": { + "receipt": { + "format": "eip712", + "payload": { + "version": 1, + "network": "eip155:8453", + "transport": "http", + "resourceUrl": "https://api.example.com/weather/SF", + "method": "GET", + "pathTemplate": "/weather/:city", + "operationId": "weather.getCurrent", + "policyVersion": "2026-04-04", + "canonicalization": "rfc8785-jcs", + "digestAlgorithm": "sha-256", + "bindPathParams": true, + "bindQuery": true, + "bindBody": false, + "operationDigest": "8db6ee2cc9d1f19dbf9d502f92c0edcf3f48c8a7f8fd5378d8cebd1d7955db6b", + "payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66", + "issuedAt": 1775289900 + }, + "signature": "0x1234567890abcdef..." + } + } + } + } +} +``` + +### Example B: `POST /search` + +#### Request + +```http +POST /search HTTP/1.1 +Host: api.example.com +Content-Type: application/json + +{"query":"x402","limit":10} +``` + +#### Validated Request State + +```json +{ + "pathParams": null, + "query": null, + "body": { + "query": "x402", + "limit": 10 + } +} +``` + +#### Logical Input Object + +```json +{ + "version": 1, + "transport": "http", + "resourceUrl": "https://api.example.com/search", + "method": "POST", + "pathTemplate": "/search", + "operationId": "search.run", + "policyVersion": "2026-04-04", + "pathParams": null, + "query": null, + "body": { + "query": "x402", + "limit": 10 + } +} +``` + +--- + +## Security Considerations + +- Servers MUST compute the digest after validation, not from raw request bytes. +- Servers MUST use exact RFC 8785 canonicalization. +- Servers MUST ensure `operationId`, `pathTemplate`, and `policyVersion` are stable and not attacker-controlled. +- Servers SHOULD use short-lived payment requirements when operation semantics are time-sensitive. +- Servers SHOULD combine this extension with `payment-identifier` for retry safety. +- Servers MUST NOT advertise this extension for request bodies they cannot canonicalize reproducibly. + +--- + +## Privacy Considerations + +- `operationDigest` is a one-way hash of validated request inputs, but the receipt still reveals route-level metadata such as `resourceUrl`, `pathTemplate`, and `operationId`. +- Deployments SHOULD avoid putting sensitive secrets into bound query or body fields when receipts may be stored or shared. +- This extension intentionally does not include amount, fee, or transaction hash in version `1`; those details belong to adjacent receipt layers. + +--- + +## References + +- [RFC 8785: JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) +- [Offer and Receipt Extension](./extension-offer-and-receipt.md) +- [Payment Identifier Extension](./payment_identifier.md) +- [Facilitator-side attestation discussion](https://github.com/x402-foundation/x402/issues/1802) + +--- + +## Version History + +| Version | Date | Changes | Author | +| --- | --- | --- | --- | +| 0.1 | 2026-04-04 | Initial companion extension draft for operation-bound receipts. | Ayush Ozha | From e5662238a291e67bceed37f3de57513bba5b7be1 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Sat, 4 Apr 2026 01:47:14 -0700 Subject: [PATCH 2/6] feat: add operation-binding extension utilities --- typescript/packages/extensions/package.json | 10 + typescript/packages/extensions/src/index.ts | 45 +++ .../src/operation-binding/digest.ts | 138 +++++++++ .../extensions/src/operation-binding/index.ts | 60 ++++ .../src/operation-binding/resourceServer.ts | 85 ++++++ .../src/operation-binding/schema.ts | 34 +++ .../src/operation-binding/signing.ts | 228 ++++++++++++++ .../extensions/src/operation-binding/types.ts | 131 +++++++++ .../extensions/test/operation-binding.test.ts | 278 ++++++++++++++++++ typescript/packages/extensions/tsup.config.ts | 1 + 10 files changed, 1010 insertions(+) create mode 100644 typescript/packages/extensions/src/operation-binding/digest.ts create mode 100644 typescript/packages/extensions/src/operation-binding/index.ts create mode 100644 typescript/packages/extensions/src/operation-binding/resourceServer.ts create mode 100644 typescript/packages/extensions/src/operation-binding/schema.ts create mode 100644 typescript/packages/extensions/src/operation-binding/signing.ts create mode 100644 typescript/packages/extensions/src/operation-binding/types.ts create mode 100644 typescript/packages/extensions/test/operation-binding.test.ts diff --git a/typescript/packages/extensions/package.json b/typescript/packages/extensions/package.json index 696ae43a1f..cce80f05cd 100644 --- a/typescript/packages/extensions/package.json +++ b/typescript/packages/extensions/package.json @@ -94,6 +94,16 @@ "default": "./dist/cjs/offer-receipt/index.js" } }, + "./operation-binding": { + "import": { + "types": "./dist/esm/operation-binding/index.d.mts", + "default": "./dist/esm/operation-binding/index.mjs" + }, + "require": { + "types": "./dist/cjs/operation-binding/index.d.ts", + "default": "./dist/cjs/operation-binding/index.js" + } + }, "./payment-identifier": { "import": { "types": "./dist/esm/payment-identifier/index.d.mts", diff --git a/typescript/packages/extensions/src/index.ts b/typescript/packages/extensions/src/index.ts index a8e18bfd60..c44df9c7e5 100644 --- a/typescript/packages/extensions/src/index.ts +++ b/typescript/packages/extensions/src/index.ts @@ -11,6 +11,51 @@ export * from "./sign-in-with-x"; // Offer/Receipt extension export * from "./offer-receipt"; +// Operation-binding extension +export { + OPERATION_BINDING, + operationBindingSchema, + isEIP712OperationReceipt, + isJWSOperationReceipt, + createOperationBindingInput, + getOperationBindingCanonicalBytes, + computeOperationDigest, + verifyOperationReceiptMatchesInput, + createOperationReceiptDomain, + OPERATION_RECEIPT_TYPES, + prepareOperationReceiptForEIP712, + hashOperationReceiptTypedData, + createOperationReceiptPayload, + createOperationReceiptJWS, + createOperationReceiptEIP712, + extractOperationReceiptPayload, + verifyOperationReceiptSignatureEIP712, + verifyOperationReceiptSignatureJWS, + declareOperationBindingExtension, + operationBindingResourceServerExtension, + type JsonArray, + type JsonObject, + type JsonPrimitive, + type JsonValue, + type OperationBindingCanonicalization, + type OperationBindingComponents, + type OperationBindingDeclaration, + type OperationBindingDigestAlgorithm, + type OperationBindingExtension, + type OperationBindingInfo, + type OperationBindingLogicalInput, + type OperationBindingSchema, + type OperationBindingSignatureFormat, + type OperationBindingTransport, + type OperationReceiptInput, + type OperationReceiptPayload, + type SignedOperationReceipt, + type JWSOperationReceipt, + type EIP712OperationReceipt, + type EIP712VerificationResult as OperationBindingEIP712VerificationResult, + type SignTypedDataFn as OperationBindingSignTypedDataFn, +} from "./operation-binding"; + // Payment-identifier extension export * from "./payment-identifier"; export { paymentIdentifierResourceServerExtension } from "./payment-identifier/resourceServer"; diff --git a/typescript/packages/extensions/src/operation-binding/digest.ts b/typescript/packages/extensions/src/operation-binding/digest.ts new file mode 100644 index 0000000000..dd4014b25f --- /dev/null +++ b/typescript/packages/extensions/src/operation-binding/digest.ts @@ -0,0 +1,138 @@ +import { canonicalize } from "../offer-receipt/signing"; +import type { + JsonObject, + JsonValue, + OperationBindingComponents, + OperationBindingInfo, + OperationBindingLogicalInput, + OperationReceiptPayload, +} from "./types"; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function normalizeJsonValue(value: unknown): JsonValue { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Operation-binding only supports finite JSON numbers"); + } + return Object.is(value, -0) ? 0 : value; + } + + if (Array.isArray(value)) { + return value.map(item => normalizeJsonValue(item)); + } + + if (isPlainObject(value)) { + const result: JsonObject = {}; + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) { + result[key] = normalizeJsonValue(entry); + } + } + return result; + } + + throw new Error("Operation-binding only supports JSON-compatible values"); +} + +function normalizeJsonObject( + value: Record | null | undefined, + fieldName: string, +): JsonObject | null { + if (value === null || value === undefined) { + return null; + } + + if (!isPlainObject(value)) { + throw new Error(`${fieldName} must be a JSON object when provided`); + } + + return normalizeJsonValue(value) as JsonObject; +} + +export function createOperationBindingInput( + binding: OperationBindingInfo, + components: OperationBindingComponents = {}, +): OperationBindingLogicalInput { + return { + version: 1, + transport: binding.transport, + resourceUrl: binding.resourceUrl, + method: binding.method.toUpperCase(), + pathTemplate: binding.pathTemplate, + operationId: binding.operationId, + policyVersion: binding.policyVersion, + pathParams: binding.bindPathParams + ? normalizeJsonObject(components.pathParams ?? null, "pathParams") + : null, + query: binding.bindQuery ? normalizeJsonObject(components.query ?? null, "query") : null, + body: binding.bindBody ? normalizeJsonValue(components.body ?? null) : null, + }; +} + +export function getOperationBindingCanonicalBytes( + binding: OperationBindingInfo, + components: OperationBindingComponents = {}, +): Uint8Array { + const input = createOperationBindingInput(binding, components); + return new TextEncoder().encode(canonicalize(input)); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + const hashBuffer = await crypto.subtle.digest("SHA-256", copy); + return Array.from(new Uint8Array(hashBuffer)) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export async function computeOperationDigest( + binding: OperationBindingInfo, + components: OperationBindingComponents = {}, +): Promise { + const bytes = getOperationBindingCanonicalBytes(binding, components); + return sha256Hex(bytes); +} + +export async function verifyOperationReceiptMatchesInput( + payload: OperationReceiptPayload, + components: OperationBindingComponents = {}, +): Promise<{ + matches: boolean; + expectedDigest: string; + actualDigest: string; +}> { + const expectedDigest = await computeOperationDigest( + { + transport: payload.transport, + resourceUrl: payload.resourceUrl, + method: payload.method, + pathTemplate: payload.pathTemplate, + operationId: payload.operationId, + policyVersion: payload.policyVersion, + canonicalization: payload.canonicalization, + digestAlgorithm: payload.digestAlgorithm, + bindPathParams: payload.bindPathParams, + bindQuery: payload.bindQuery, + bindBody: payload.bindBody, + }, + components, + ); + + return { + matches: expectedDigest === payload.operationDigest, + expectedDigest, + actualDigest: payload.operationDigest, + }; +} diff --git a/typescript/packages/extensions/src/operation-binding/index.ts b/typescript/packages/extensions/src/operation-binding/index.ts new file mode 100644 index 0000000000..1e462f12e6 --- /dev/null +++ b/typescript/packages/extensions/src/operation-binding/index.ts @@ -0,0 +1,60 @@ +/** + * x402 Operation-Binding Extension + */ + +export type { + JsonArray, + JsonObject, + JsonPrimitive, + JsonValue, + OperationBindingCanonicalization, + OperationBindingComponents, + OperationBindingDeclaration, + OperationBindingDigestAlgorithm, + OperationBindingExtension, + OperationBindingInfo, + OperationBindingLogicalInput, + OperationBindingSchema, + OperationBindingSignatureFormat, + OperationBindingTransport, + OperationReceiptInput, + OperationReceiptPayload, + SignedOperationReceipt, + JWSOperationReceipt, + EIP712OperationReceipt, +} from "./types"; + +export { + OPERATION_BINDING, + isEIP712OperationReceipt, + isJWSOperationReceipt, +} from "./types"; + +export { operationBindingSchema } from "./schema"; + +export { + createOperationBindingInput, + getOperationBindingCanonicalBytes, + computeOperationDigest, + verifyOperationReceiptMatchesInput, +} from "./digest"; + +export { + createOperationReceiptDomain, + OPERATION_RECEIPT_TYPES, + prepareOperationReceiptForEIP712, + hashOperationReceiptTypedData, + createOperationReceiptPayload, + createOperationReceiptJWS, + createOperationReceiptEIP712, + extractOperationReceiptPayload, + verifyOperationReceiptSignatureEIP712, + verifyOperationReceiptSignatureJWS, + type EIP712VerificationResult, + type SignTypedDataFn, +} from "./signing"; + +export { + declareOperationBindingExtension, + operationBindingResourceServerExtension, +} from "./resourceServer"; diff --git a/typescript/packages/extensions/src/operation-binding/resourceServer.ts b/typescript/packages/extensions/src/operation-binding/resourceServer.ts new file mode 100644 index 0000000000..294dfe8c73 --- /dev/null +++ b/typescript/packages/extensions/src/operation-binding/resourceServer.ts @@ -0,0 +1,85 @@ +import type { PaymentRequiredContext, ResourceServerExtension } from "@x402/core/types"; +import type { HTTPTransportContext } from "@x402/core/http"; +import { OPERATION_BINDING } from "./types"; +import type { + OperationBindingDeclaration, + OperationBindingExtension, + OperationBindingInfo, +} from "./types"; +import { operationBindingSchema } from "./schema"; + +const BRACKET_PARAM_REGEX_ALL = /\[([^\]]+)\]/g; + +function normalizePathTemplate(routePattern: string): string { + return routePattern.replace(BRACKET_PARAM_REGEX_ALL, ":$1"); +} + +function isHTTPTransportContext(value: unknown): value is HTTPTransportContext { + return value !== null && typeof value === "object" && "request" in value; +} + +function toOperationBindingInfo( + declaration: OperationBindingDeclaration, + context: PaymentRequiredContext, +): OperationBindingInfo | undefined { + if (!isHTTPTransportContext(context.transportContext)) { + return undefined; + } + + const request = context.transportContext.request; + const resourceUrl = context.paymentRequiredResponse.resource?.url ?? context.resourceInfo.url; + const method = request.method?.toUpperCase(); + const pathTemplate = request.routePattern + ? normalizePathTemplate(request.routePattern) + : request.adapter.getPath(); + + if (!resourceUrl || !method || !pathTemplate) { + return undefined; + } + + return { + transport: "http", + resourceUrl, + method, + pathTemplate, + operationId: declaration.operationId, + policyVersion: declaration.policyVersion, + canonicalization: "rfc8785-jcs", + digestAlgorithm: "sha-256", + bindPathParams: declaration.bindPathParams ?? true, + bindQuery: declaration.bindQuery ?? true, + bindBody: declaration.bindBody ?? true, + }; +} + +export function declareOperationBindingExtension( + declaration: OperationBindingDeclaration, +): OperationBindingDeclaration { + return { + operationId: declaration.operationId, + policyVersion: declaration.policyVersion, + bindPathParams: declaration.bindPathParams ?? true, + bindQuery: declaration.bindQuery ?? true, + bindBody: declaration.bindBody ?? true, + }; +} + +export const operationBindingResourceServerExtension: ResourceServerExtension = { + key: OPERATION_BINDING, + + enrichPaymentRequiredResponse: async ( + declaration: unknown, + context: PaymentRequiredContext, + ): Promise => { + const info = toOperationBindingInfo(declaration as OperationBindingDeclaration, context); + if (!info) { + return undefined; + } + + return { + info, + schema: operationBindingSchema, + }; + }, +}; + diff --git a/typescript/packages/extensions/src/operation-binding/schema.ts b/typescript/packages/extensions/src/operation-binding/schema.ts new file mode 100644 index 0000000000..9f6e3583a9 --- /dev/null +++ b/typescript/packages/extensions/src/operation-binding/schema.ts @@ -0,0 +1,34 @@ +import type { OperationBindingSchema } from "./types"; + +export const operationBindingSchema: OperationBindingSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + transport: { type: "string", const: "http" }, + resourceUrl: { type: "string", format: "uri" }, + method: { type: "string" }, + pathTemplate: { type: "string" }, + operationId: { type: "string" }, + policyVersion: { type: "string" }, + canonicalization: { type: "string", const: "rfc8785-jcs" }, + digestAlgorithm: { type: "string", const: "sha-256" }, + bindPathParams: { type: "boolean" }, + bindQuery: { type: "boolean" }, + bindBody: { type: "boolean" }, + }, + required: [ + "transport", + "resourceUrl", + "method", + "pathTemplate", + "operationId", + "policyVersion", + "canonicalization", + "digestAlgorithm", + "bindPathParams", + "bindQuery", + "bindBody", + ], + additionalProperties: false, +}; + diff --git a/typescript/packages/extensions/src/operation-binding/signing.ts b/typescript/packages/extensions/src/operation-binding/signing.ts new file mode 100644 index 0000000000..3596adc45f --- /dev/null +++ b/typescript/packages/extensions/src/operation-binding/signing.ts @@ -0,0 +1,228 @@ +import * as jose from "jose"; +import { hashTypedData, recoverTypedDataAddress, type Hex, type TypedDataDomain } from "viem"; +import type { JWSSigner } from "../offer-receipt/types"; +import { createJWS, extractJWSHeader, extractJWSPayload } from "../offer-receipt/signing"; +import { extractPublicKeyFromKid } from "../offer-receipt/did"; +import { computeOperationDigest } from "./digest"; +import type { + EIP712OperationReceipt, + JWSOperationReceipt, + OperationReceiptInput, + OperationReceiptPayload, + SignedOperationReceipt, +} from "./types"; +import { isEIP712OperationReceipt, isJWSOperationReceipt } from "./types"; + +const OPERATION_RECEIPT_VERSION = 1; + +export function createOperationReceiptDomain(): TypedDataDomain { + return { name: "x402 operation receipt", version: "1", chainId: 1 }; +} + +export const OPERATION_RECEIPT_TYPES = { + OperationReceipt: [ + { name: "version", type: "uint256" }, + { name: "network", type: "string" }, + { name: "transport", type: "string" }, + { name: "resourceUrl", type: "string" }, + { name: "method", type: "string" }, + { name: "pathTemplate", type: "string" }, + { name: "operationId", type: "string" }, + { name: "policyVersion", type: "string" }, + { name: "canonicalization", type: "string" }, + { name: "digestAlgorithm", type: "string" }, + { name: "bindPathParams", type: "bool" }, + { name: "bindQuery", type: "bool" }, + { name: "bindBody", type: "bool" }, + { name: "operationDigest", type: "string" }, + { name: "payer", type: "string" }, + { name: "issuedAt", type: "uint256" }, + ], +}; + +export type SignTypedDataFn = (params: { + domain: TypedDataDomain; + types: Record>; + primaryType: string; + message: Record; +}) => Promise; + +export function prepareOperationReceiptForEIP712(payload: OperationReceiptPayload): { + version: bigint; + network: string; + transport: string; + resourceUrl: string; + method: string; + pathTemplate: string; + operationId: string; + policyVersion: string; + canonicalization: string; + digestAlgorithm: string; + bindPathParams: boolean; + bindQuery: boolean; + bindBody: boolean; + operationDigest: string; + payer: string; + issuedAt: bigint; +} { + return { + version: BigInt(payload.version), + network: payload.network, + transport: payload.transport, + resourceUrl: payload.resourceUrl, + method: payload.method, + pathTemplate: payload.pathTemplate, + operationId: payload.operationId, + policyVersion: payload.policyVersion, + canonicalization: payload.canonicalization, + digestAlgorithm: payload.digestAlgorithm, + bindPathParams: payload.bindPathParams, + bindQuery: payload.bindQuery, + bindBody: payload.bindBody, + operationDigest: payload.operationDigest, + payer: payload.payer, + issuedAt: BigInt(payload.issuedAt), + }; +} + +export function hashOperationReceiptTypedData(payload: OperationReceiptPayload): Hex { + return hashTypedData({ + domain: createOperationReceiptDomain(), + types: OPERATION_RECEIPT_TYPES, + primaryType: "OperationReceipt", + message: prepareOperationReceiptForEIP712(payload), + }); +} + +export async function createOperationReceiptPayload( + input: OperationReceiptInput, +): Promise { + const operationDigest = await computeOperationDigest(input.binding, input); + + return { + version: OPERATION_RECEIPT_VERSION, + network: input.network, + transport: input.binding.transport, + resourceUrl: input.binding.resourceUrl, + method: input.binding.method, + pathTemplate: input.binding.pathTemplate, + operationId: input.binding.operationId, + policyVersion: input.binding.policyVersion, + canonicalization: input.binding.canonicalization, + digestAlgorithm: input.binding.digestAlgorithm, + bindPathParams: input.binding.bindPathParams, + bindQuery: input.binding.bindQuery, + bindBody: input.binding.bindBody, + operationDigest, + payer: input.payer, + issuedAt: input.issuedAt ?? Math.floor(Date.now() / 1000), + }; +} + +export async function createOperationReceiptJWS( + input: OperationReceiptInput, + signer: JWSSigner, +): Promise { + const payload = await createOperationReceiptPayload(input); + const signature = await createJWS(payload, signer); + return { + format: "jws", + signature, + }; +} + +export async function createOperationReceiptEIP712( + input: OperationReceiptInput, + signTypedData: SignTypedDataFn, +): Promise { + const payload = await createOperationReceiptPayload(input); + const signature = await signTypedData({ + domain: createOperationReceiptDomain(), + types: OPERATION_RECEIPT_TYPES, + primaryType: "OperationReceipt", + message: prepareOperationReceiptForEIP712(payload) as unknown as Record, + }); + + return { + format: "eip712", + payload, + signature, + }; +} + +export function extractOperationReceiptPayload( + receipt: SignedOperationReceipt, +): OperationReceiptPayload { + if (isJWSOperationReceipt(receipt)) { + return extractJWSPayload(receipt.signature); + } + + if (isEIP712OperationReceipt(receipt)) { + return receipt.payload; + } + + throw new Error(`Unknown receipt format: ${(receipt as SignedOperationReceipt).format}`); +} + +export interface EIP712VerificationResult { + signer: Hex; + payload: T; +} + +export async function verifyOperationReceiptSignatureEIP712( + receipt: EIP712OperationReceipt, +): Promise> { + if (receipt.format !== "eip712") { + throw new Error(`Expected eip712 format, got ${receipt.format}`); + } + + const signer = await recoverTypedDataAddress({ + domain: createOperationReceiptDomain(), + types: OPERATION_RECEIPT_TYPES, + primaryType: "OperationReceipt", + message: prepareOperationReceiptForEIP712(receipt.payload), + signature: receipt.signature as Hex, + }); + + return { + signer, + payload: receipt.payload, + }; +} + +async function resolveVerificationKey( + jws: string, + providedKey?: jose.KeyLike | jose.JWK, +): Promise { + if (providedKey) { + if ("kty" in providedKey) { + const key = await jose.importJWK(providedKey); + if (key instanceof Uint8Array) { + throw new Error("Symmetric keys are not supported for JWS verification"); + } + return key; + } + return providedKey; + } + + const header = extractJWSHeader(jws); + if (!header.kid) { + throw new Error("No public key provided and JWS header missing kid"); + } + + return extractPublicKeyFromKid(header.kid); +} + +export async function verifyOperationReceiptSignatureJWS( + receipt: JWSOperationReceipt, + publicKey?: jose.KeyLike | jose.JWK, +): Promise { + if (receipt.format !== "jws") { + throw new Error(`Expected jws format, got ${receipt.format}`); + } + + const key = await resolveVerificationKey(receipt.signature, publicKey); + const { payload } = await jose.compactVerify(receipt.signature, key); + return JSON.parse(new TextDecoder().decode(payload)) as OperationReceiptPayload; +} + diff --git a/typescript/packages/extensions/src/operation-binding/types.ts b/typescript/packages/extensions/src/operation-binding/types.ts new file mode 100644 index 0000000000..7b39cf8ab3 --- /dev/null +++ b/typescript/packages/extensions/src/operation-binding/types.ts @@ -0,0 +1,131 @@ +/** + * Type definitions for the x402 Operation-Binding Extension + * + * This companion extension binds a payment receipt to one exact validated + * HTTP operation by hashing a canonical logical input object. + */ + +export const OPERATION_BINDING = "operation-binding"; + +export type OperationBindingTransport = "http"; +export type OperationBindingCanonicalization = "rfc8785-jcs"; +export type OperationBindingDigestAlgorithm = "sha-256"; +export type OperationBindingSignatureFormat = "jws" | "eip712"; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; +export interface JsonObject { + [key: string]: JsonValue; +} +export type JsonArray = JsonValue[]; + +/** + * Static route declaration used by resource servers. + * Dynamic request fields (method, resourceUrl, pathTemplate) are enriched from + * HTTP transport context when generating PaymentRequired responses. + */ +export interface OperationBindingDeclaration { + operationId: string; + policyVersion: string; + bindPathParams?: boolean; + bindQuery?: boolean; + bindBody?: boolean; +} + +export interface OperationBindingInfo { + transport: OperationBindingTransport; + resourceUrl: string; + method: string; + pathTemplate: string; + operationId: string; + policyVersion: string; + canonicalization: OperationBindingCanonicalization; + digestAlgorithm: OperationBindingDigestAlgorithm; + bindPathParams: boolean; + bindQuery: boolean; + bindBody: boolean; +} + +export interface OperationBindingSchema { + $schema: "https://json-schema.org/draft/2020-12/schema"; + type: "object"; + properties: Record; + required: string[]; + additionalProperties: false; +} + +export interface OperationBindingExtension { + info: OperationBindingInfo; + schema: OperationBindingSchema; +} + +export interface OperationBindingLogicalInput { + version: 1; + transport: OperationBindingTransport; + resourceUrl: string; + method: string; + pathTemplate: string; + operationId: string; + policyVersion: string; + pathParams: JsonObject | null; + query: JsonObject | null; + body: JsonValue | null; +} + +export interface OperationBindingComponents { + pathParams?: Record | null; + query?: Record | null; + body?: unknown; +} + +export interface OperationReceiptPayload { + version: number; + network: string; + transport: OperationBindingTransport; + resourceUrl: string; + method: string; + pathTemplate: string; + operationId: string; + policyVersion: string; + canonicalization: OperationBindingCanonicalization; + digestAlgorithm: OperationBindingDigestAlgorithm; + bindPathParams: boolean; + bindQuery: boolean; + bindBody: boolean; + operationDigest: string; + payer: string; + issuedAt: number; +} + +export interface OperationReceiptInput extends OperationBindingComponents { + binding: OperationBindingInfo; + payer: string; + network: string; + issuedAt?: number; +} + +export interface JWSOperationReceipt { + format: "jws"; + signature: string; +} + +export interface EIP712OperationReceipt { + format: "eip712"; + payload: OperationReceiptPayload; + signature: string; +} + +export type SignedOperationReceipt = JWSOperationReceipt | EIP712OperationReceipt; + +export function isJWSOperationReceipt( + receipt: SignedOperationReceipt, +): receipt is JWSOperationReceipt { + return receipt.format === "jws"; +} + +export function isEIP712OperationReceipt( + receipt: SignedOperationReceipt, +): receipt is EIP712OperationReceipt { + return receipt.format === "eip712"; +} + diff --git a/typescript/packages/extensions/test/operation-binding.test.ts b/typescript/packages/extensions/test/operation-binding.test.ts new file mode 100644 index 0000000000..692e672ee2 --- /dev/null +++ b/typescript/packages/extensions/test/operation-binding.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from "vitest"; +import type { PaymentRequired } from "@x402/core"; +import { privateKeyToAccount } from "viem/accounts"; +import type { Hex } from "viem"; + +import { + OPERATION_BINDING, + operationBindingSchema, + declareOperationBindingExtension, + operationBindingResourceServerExtension, + createOperationBindingInput, + getOperationBindingCanonicalBytes, + computeOperationDigest, + createOperationReceiptJWS, + createOperationReceiptEIP712, + extractOperationReceiptPayload, + verifyOperationReceiptSignatureJWS, + verifyOperationReceiptSignatureEIP712, + verifyOperationReceiptMatchesInput, + type OperationBindingInfo, +} from "../src/operation-binding"; +import { createES256KSigner, generateES256KKeyPair } from "./offer-receipt-test-utils"; + +const TEST_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; + +const WEATHER_BINDING: OperationBindingInfo = { + transport: "http", + resourceUrl: "https://api.example.com/weather/SF", + method: "GET", + pathTemplate: "/weather/:city", + operationId: "weather.getCurrent", + policyVersion: "2026-04-04", + canonicalization: "rfc8785-jcs", + digestAlgorithm: "sha-256", + bindPathParams: true, + bindQuery: true, + bindBody: false, +}; + +describe("Operation-Binding Extension", () => { + describe("declareOperationBindingExtension", () => { + it("normalizes defaults for binding flags", () => { + const declaration = declareOperationBindingExtension({ + operationId: "weather.getCurrent", + policyVersion: "2026-04-04", + }); + + expect(declaration).toEqual({ + operationId: "weather.getCurrent", + policyVersion: "2026-04-04", + bindPathParams: true, + bindQuery: true, + bindBody: true, + }); + }); + }); + + describe("operationBindingResourceServerExtension", () => { + it("exports the correct extension key", () => { + expect(operationBindingResourceServerExtension.key).toBe(OPERATION_BINDING); + }); + + it("enriches PaymentRequired with HTTP-specific operation metadata", async () => { + const declaration = declareOperationBindingExtension({ + operationId: "weather.getCurrent", + policyVersion: "2026-04-04", + bindBody: false, + }); + + const paymentRequired: PaymentRequired = { + x402Version: 2, + resource: { + url: "https://api.example.com/weather/SF", + description: "Weather lookup", + mimeType: "application/json", + }, + accepts: [], + }; + + const result = await operationBindingResourceServerExtension.enrichPaymentRequiredResponse!( + declaration, + { + requirements: [], + resourceInfo: paymentRequired.resource, + paymentRequiredResponse: paymentRequired, + transportContext: { + request: { + method: "GET", + routePattern: "/weather/[city]", + adapter: { + getHeader: () => undefined, + getMethod: () => "GET", + getPath: () => "/weather/SF", + getUrl: () => "https://api.example.com/weather/SF", + getAcceptHeader: () => "application/json", + getUserAgent: () => "vitest", + }, + path: "/weather/SF", + adapterMethod: "GET", + }, + }, + } as never, + ); + + expect(result).toEqual({ + info: { + transport: "http", + resourceUrl: "https://api.example.com/weather/SF", + method: "GET", + pathTemplate: "/weather/:city", + operationId: "weather.getCurrent", + policyVersion: "2026-04-04", + canonicalization: "rfc8785-jcs", + digestAlgorithm: "sha-256", + bindPathParams: true, + bindQuery: true, + bindBody: false, + }, + schema: operationBindingSchema, + }); + }); + }); + + describe("digest computation", () => { + it("creates a stable logical input object", () => { + const input = createOperationBindingInput(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { units: "metric" }, + body: { ignored: true }, + }); + + expect(input).toEqual({ + version: 1, + transport: "http", + resourceUrl: "https://api.example.com/weather/SF", + method: "GET", + pathTemplate: "/weather/:city", + operationId: "weather.getCurrent", + policyVersion: "2026-04-04", + pathParams: { city: "SF" }, + query: { units: "metric" }, + body: null, + }); + }); + + it("produces identical canonical bytes regardless of object key order", () => { + const canonicalA = getOperationBindingCanonicalBytes(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { units: "metric", lang: "en" }, + }); + const canonicalB = getOperationBindingCanonicalBytes(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { lang: "en", units: "metric" }, + }); + + expect(new TextDecoder().decode(canonicalA)).toBe(new TextDecoder().decode(canonicalB)); + }); + + it("produces the same digest for logically equivalent objects", async () => { + const digestA = await computeOperationDigest(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { units: "metric", lang: "en" }, + }); + const digestB = await computeOperationDigest(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { lang: "en", units: "metric" }, + }); + + expect(digestA).toBe(digestB); + }); + + it("changes the digest when a bound field changes", async () => { + const digestA = await computeOperationDigest(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { units: "metric" }, + }); + const digestB = await computeOperationDigest(WEATHER_BINDING, { + pathParams: { city: "SF" }, + query: { units: "imperial" }, + }); + + expect(digestA).not.toBe(digestB); + }); + }); + + describe("receipt signing and verification", () => { + it("creates and verifies a JWS operation receipt", async () => { + const keyPair = await generateES256KKeyPair(); + const signer = await createES256KSigner(keyPair.privateKey, "did:web:example.com#key-1"); + + const receipt = await createOperationReceiptJWS( + { + binding: WEATHER_BINDING, + payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66", + network: "eip155:8453", + pathParams: { city: "SF" }, + query: { units: "metric" }, + }, + signer, + ); + + expect(receipt.format).toBe("jws"); + + const extracted = extractOperationReceiptPayload(receipt); + const verified = await verifyOperationReceiptSignatureJWS(receipt, keyPair.publicKey); + + expect(verified.operationDigest).toBe(extracted.operationDigest); + expect(verified.operationId).toBe("weather.getCurrent"); + expect(verified.pathTemplate).toBe("/weather/:city"); + }); + + it("creates and verifies an EIP-712 operation receipt", async () => { + const account = privateKeyToAccount(TEST_PRIVATE_KEY); + + const receipt = await createOperationReceiptEIP712( + { + binding: { + ...WEATHER_BINDING, + method: "POST", + resourceUrl: "https://api.example.com/search", + pathTemplate: "/search", + operationId: "search.run", + bindPathParams: false, + bindQuery: false, + bindBody: true, + }, + payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66", + network: "eip155:8453", + body: { + query: "x402", + limit: 10, + }, + issuedAt: 1775289900, + }, + params => account.signTypedData(params), + ); + + expect(receipt.format).toBe("eip712"); + + const verification = await verifyOperationReceiptSignatureEIP712(receipt); + expect(verification.signer.toLowerCase()).toBe(account.address.toLowerCase()); + expect(verification.payload.operationId).toBe("search.run"); + expect(verification.payload.bindBody).toBe(true); + }); + + it("detects a digest mismatch when bound inputs change", async () => { + const keyPair = await generateES256KKeyPair(); + const signer = await createES256KSigner(keyPair.privateKey, "did:web:example.com#key-1"); + + const receipt = await createOperationReceiptJWS( + { + binding: WEATHER_BINDING, + payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66", + network: "eip155:8453", + pathParams: { city: "SF" }, + query: { units: "metric" }, + }, + signer, + ); + + const payload = await verifyOperationReceiptSignatureJWS(receipt, keyPair.publicKey); + + const match = await verifyOperationReceiptMatchesInput(payload, { + pathParams: { city: "SF" }, + query: { units: "metric" }, + }); + expect(match.matches).toBe(true); + + const mismatch = await verifyOperationReceiptMatchesInput(payload, { + pathParams: { city: "SF" }, + query: { units: "imperial" }, + }); + expect(mismatch.matches).toBe(false); + expect(mismatch.expectedDigest).not.toBe(mismatch.actualDigest); + }); + }); +}); diff --git a/typescript/packages/extensions/tsup.config.ts b/typescript/packages/extensions/tsup.config.ts index bd85a73925..c8d98bb124 100644 --- a/typescript/packages/extensions/tsup.config.ts +++ b/typescript/packages/extensions/tsup.config.ts @@ -6,6 +6,7 @@ const baseConfig = { "bazaar/index": "src/bazaar/index.ts", "sign-in-with-x/index": "src/sign-in-with-x/index.ts", "offer-receipt/index": "src/offer-receipt/index.ts", + "operation-binding/index": "src/operation-binding/index.ts", "payment-identifier/index": "src/payment-identifier/index.ts", }, dts: { From 2d57b986ec3d365540107b5fbd4228f02c1edf18 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Sat, 4 Apr 2026 09:07:14 -0700 Subject: [PATCH 3/6] fix: satisfy operation-binding lint rules --- .../src/operation-binding/digest.ts | 53 +++++++++++++++ .../extensions/src/operation-binding/index.ts | 6 +- .../src/operation-binding/resourceServer.ts | 26 ++++++- .../src/operation-binding/schema.ts | 1 - .../src/operation-binding/signing.ts | 67 ++++++++++++++++++- .../extensions/src/operation-binding/types.ts | 13 +++- 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/typescript/packages/extensions/src/operation-binding/digest.ts b/typescript/packages/extensions/src/operation-binding/digest.ts index dd4014b25f..cf7744cedc 100644 --- a/typescript/packages/extensions/src/operation-binding/digest.ts +++ b/typescript/packages/extensions/src/operation-binding/digest.ts @@ -8,10 +8,22 @@ import type { OperationReceiptPayload, } from "./types"; +/** + * Check whether a value can be normalized as a JSON object. + * + * @param value - Candidate value from caller input. + * @returns `true` when the value is a non-array object. + */ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** + * Normalize arbitrary input into the JSON subset supported by operation binding. + * + * @param value - Candidate JSON-compatible value. + * @returns A normalized JSON value with `undefined` removed and `-0` rewritten to `0`. + */ function normalizeJsonValue(value: unknown): JsonValue { if (value === null || value === undefined) { return null; @@ -45,6 +57,13 @@ function normalizeJsonValue(value: unknown): JsonValue { throw new Error("Operation-binding only supports JSON-compatible values"); } +/** + * Normalize a top-level object field used in the logical binding input. + * + * @param value - Candidate object value for the field. + * @param fieldName - Human-readable field name for validation errors. + * @returns A normalized JSON object or `null` when omitted. + */ function normalizeJsonObject( value: Record | null | undefined, fieldName: string, @@ -60,6 +79,13 @@ function normalizeJsonObject( return normalizeJsonValue(value) as JsonObject; } +/** + * Build the logical input that is hashed for an operation-bound receipt. + * + * @param binding - Static and enriched binding metadata for the operation. + * @param components - Validated request components that may be bound into the digest. + * @returns The normalized logical input object used for canonicalization. + */ export function createOperationBindingInput( binding: OperationBindingInfo, components: OperationBindingComponents = {}, @@ -80,6 +106,13 @@ export function createOperationBindingInput( }; } +/** + * Canonicalize an operation-binding input and return its UTF-8 bytes. + * + * @param binding - Static and enriched binding metadata for the operation. + * @param components - Validated request components that may be bound into the digest. + * @returns UTF-8 bytes of the canonicalized logical input. + */ export function getOperationBindingCanonicalBytes( binding: OperationBindingInfo, components: OperationBindingComponents = {}, @@ -88,6 +121,12 @@ export function getOperationBindingCanonicalBytes( return new TextEncoder().encode(canonicalize(input)); } +/** + * Compute a SHA-256 digest and encode it as lowercase hexadecimal. + * + * @param bytes - Canonical bytes to hash. + * @returns Lowercase hexadecimal digest output. + */ async function sha256Hex(bytes: Uint8Array): Promise { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); @@ -97,6 +136,13 @@ async function sha256Hex(bytes: Uint8Array): Promise { .join(""); } +/** + * Compute the operation digest for a binding plus validated request components. + * + * @param binding - Static and enriched binding metadata for the operation. + * @param components - Validated request components that may be bound into the digest. + * @returns Lowercase hexadecimal digest for the canonical logical input. + */ export async function computeOperationDigest( binding: OperationBindingInfo, components: OperationBindingComponents = {}, @@ -105,6 +151,13 @@ export async function computeOperationDigest( return sha256Hex(bytes); } +/** + * Recompute the digest for a request and compare it against a signed receipt payload. + * + * @param payload - Receipt payload containing the claimed operation digest and binding flags. + * @param components - Validated request components to compare against the receipt. + * @returns Whether the digest matches plus both the expected and actual digest values. + */ export async function verifyOperationReceiptMatchesInput( payload: OperationReceiptPayload, components: OperationBindingComponents = {}, diff --git a/typescript/packages/extensions/src/operation-binding/index.ts b/typescript/packages/extensions/src/operation-binding/index.ts index 1e462f12e6..2ac1ccf427 100644 --- a/typescript/packages/extensions/src/operation-binding/index.ts +++ b/typescript/packages/extensions/src/operation-binding/index.ts @@ -24,11 +24,7 @@ export type { EIP712OperationReceipt, } from "./types"; -export { - OPERATION_BINDING, - isEIP712OperationReceipt, - isJWSOperationReceipt, -} from "./types"; +export { OPERATION_BINDING, isEIP712OperationReceipt, isJWSOperationReceipt } from "./types"; export { operationBindingSchema } from "./schema"; diff --git a/typescript/packages/extensions/src/operation-binding/resourceServer.ts b/typescript/packages/extensions/src/operation-binding/resourceServer.ts index 294dfe8c73..447bd3713e 100644 --- a/typescript/packages/extensions/src/operation-binding/resourceServer.ts +++ b/typescript/packages/extensions/src/operation-binding/resourceServer.ts @@ -10,14 +10,33 @@ import { operationBindingSchema } from "./schema"; const BRACKET_PARAM_REGEX_ALL = /\[([^\]]+)\]/g; +/** + * Convert framework-style bracket params into the colon-prefixed form used by the spec. + * + * @param routePattern - Route pattern reported by the HTTP adapter. + * @returns A normalized path template string. + */ function normalizePathTemplate(routePattern: string): string { return routePattern.replace(BRACKET_PARAM_REGEX_ALL, ":$1"); } +/** + * Check whether a payment-required context is backed by the HTTP transport integration. + * + * @param value - Candidate transport context. + * @returns `true` when the context exposes an HTTP request object. + */ function isHTTPTransportContext(value: unknown): value is HTTPTransportContext { return value !== null && typeof value === "object" && "request" in value; } +/** + * Resolve a declaration plus HTTP request context into concrete binding metadata. + * + * @param declaration - Static operation-binding declaration configured by the server. + * @param context - Runtime payment-required context for the current request. + * @returns Fully populated binding metadata, or `undefined` when HTTP details are unavailable. + */ function toOperationBindingInfo( declaration: OperationBindingDeclaration, context: PaymentRequiredContext, @@ -52,6 +71,12 @@ function toOperationBindingInfo( }; } +/** + * Normalize a server-side declaration so omitted binding flags use the spec defaults. + * + * @param declaration - Static declaration supplied by application code. + * @returns Declaration with default binding flags filled in. + */ export function declareOperationBindingExtension( declaration: OperationBindingDeclaration, ): OperationBindingDeclaration { @@ -82,4 +107,3 @@ export const operationBindingResourceServerExtension: ResourceServerExtension = }; }, }; - diff --git a/typescript/packages/extensions/src/operation-binding/schema.ts b/typescript/packages/extensions/src/operation-binding/schema.ts index 9f6e3583a9..eedcc12774 100644 --- a/typescript/packages/extensions/src/operation-binding/schema.ts +++ b/typescript/packages/extensions/src/operation-binding/schema.ts @@ -31,4 +31,3 @@ export const operationBindingSchema: OperationBindingSchema = { ], additionalProperties: false, }; - diff --git a/typescript/packages/extensions/src/operation-binding/signing.ts b/typescript/packages/extensions/src/operation-binding/signing.ts index 3596adc45f..60f668fcef 100644 --- a/typescript/packages/extensions/src/operation-binding/signing.ts +++ b/typescript/packages/extensions/src/operation-binding/signing.ts @@ -15,6 +15,11 @@ import { isEIP712OperationReceipt, isJWSOperationReceipt } from "./types"; const OPERATION_RECEIPT_VERSION = 1; +/** + * Create the fixed EIP-712 domain used for operation-receipt signatures. + * + * @returns The typed-data domain shared by all operation receipts. + */ export function createOperationReceiptDomain(): TypedDataDomain { return { name: "x402 operation receipt", version: "1", chainId: 1 }; } @@ -47,6 +52,12 @@ export type SignTypedDataFn = (params: { message: Record; }) => Promise; +/** + * Prepare a typed-data message from an operation-receipt payload. + * + * @param payload - Receipt payload to encode for EIP-712 signing. + * @returns The bigint-normalized message object expected by `viem`. + */ export function prepareOperationReceiptForEIP712(payload: OperationReceiptPayload): { version: bigint; network: string; @@ -85,6 +96,12 @@ export function prepareOperationReceiptForEIP712(payload: OperationReceiptPayloa }; } +/** + * Hash an operation-receipt payload as EIP-712 typed data. + * + * @param payload - Receipt payload to hash. + * @returns The EIP-712 digest as a hex string. + */ export function hashOperationReceiptTypedData(payload: OperationReceiptPayload): Hex { return hashTypedData({ domain: createOperationReceiptDomain(), @@ -94,6 +111,12 @@ export function hashOperationReceiptTypedData(payload: OperationReceiptPayload): }); } +/** + * Create the canonical payload that both JWS and EIP-712 receipts sign. + * + * @param input - Operation-binding metadata plus payer and request components. + * @returns Receipt payload with a freshly computed operation digest. + */ export async function createOperationReceiptPayload( input: OperationReceiptInput, ): Promise { @@ -119,6 +142,13 @@ export async function createOperationReceiptPayload( }; } +/** + * Sign an operation receipt using the JWS format. + * + * @param input - Operation-binding metadata plus payer and request components. + * @param signer - JWS signer implementation for the receipt issuer. + * @returns Signed receipt encoded as a compact JWS. + */ export async function createOperationReceiptJWS( input: OperationReceiptInput, signer: JWSSigner, @@ -131,6 +161,13 @@ export async function createOperationReceiptJWS( }; } +/** + * Sign an operation receipt using the EIP-712 format. + * + * @param input - Operation-binding metadata plus payer and request components. + * @param signTypedData - Callback that signs the prepared typed-data message. + * @returns Signed receipt containing the payload and EIP-712 signature. + */ export async function createOperationReceiptEIP712( input: OperationReceiptInput, signTypedData: SignTypedDataFn, @@ -150,6 +187,12 @@ export async function createOperationReceiptEIP712( }; } +/** + * Extract the payload from either supported signed receipt format. + * + * @param receipt - Signed receipt in JWS or EIP-712 form. + * @returns The decoded operation-receipt payload. + */ export function extractOperationReceiptPayload( receipt: SignedOperationReceipt, ): OperationReceiptPayload { @@ -164,11 +207,20 @@ export function extractOperationReceiptPayload( throw new Error(`Unknown receipt format: ${(receipt as SignedOperationReceipt).format}`); } +/** + * Result returned after recovering the signer of an EIP-712 receipt. + */ export interface EIP712VerificationResult { signer: Hex; payload: T; } +/** + * Verify an EIP-712 receipt signature and recover the signer address. + * + * @param receipt - Signed receipt in EIP-712 form. + * @returns The recovered signer address together with the verified payload. + */ export async function verifyOperationReceiptSignatureEIP712( receipt: EIP712OperationReceipt, ): Promise> { @@ -190,6 +242,13 @@ export async function verifyOperationReceiptSignatureEIP712( }; } +/** + * Resolve the public key that should be used to verify a JWS receipt. + * + * @param jws - Compact JWS receipt string. + * @param providedKey - Optional explicit verification key or JWK. + * @returns A public key usable with `jose.compactVerify`. + */ async function resolveVerificationKey( jws: string, providedKey?: jose.KeyLike | jose.JWK, @@ -213,6 +272,13 @@ async function resolveVerificationKey( return extractPublicKeyFromKid(header.kid); } +/** + * Verify a JWS receipt signature and return the decoded payload. + * + * @param receipt - Signed receipt in JWS form. + * @param publicKey - Optional explicit verification key or JWK. + * @returns The verified operation-receipt payload. + */ export async function verifyOperationReceiptSignatureJWS( receipt: JWSOperationReceipt, publicKey?: jose.KeyLike | jose.JWK, @@ -225,4 +291,3 @@ export async function verifyOperationReceiptSignatureJWS( const { payload } = await jose.compactVerify(receipt.signature, key); return JSON.parse(new TextDecoder().decode(payload)) as OperationReceiptPayload; } - diff --git a/typescript/packages/extensions/src/operation-binding/types.ts b/typescript/packages/extensions/src/operation-binding/types.ts index 7b39cf8ab3..2ddd6d3815 100644 --- a/typescript/packages/extensions/src/operation-binding/types.ts +++ b/typescript/packages/extensions/src/operation-binding/types.ts @@ -117,15 +117,26 @@ export interface EIP712OperationReceipt { export type SignedOperationReceipt = JWSOperationReceipt | EIP712OperationReceipt; +/** + * Narrow a signed receipt to the JWS representation. + * + * @param receipt - Receipt value to inspect. + * @returns `true` when the receipt uses the JWS format. + */ export function isJWSOperationReceipt( receipt: SignedOperationReceipt, ): receipt is JWSOperationReceipt { return receipt.format === "jws"; } +/** + * Narrow a signed receipt to the EIP-712 representation. + * + * @param receipt - Receipt value to inspect. + * @returns `true` when the receipt uses the EIP-712 format. + */ export function isEIP712OperationReceipt( receipt: SignedOperationReceipt, ): receipt is EIP712OperationReceipt { return receipt.format === "eip712"; } - From 34bab63fa00a516db79b8d00c2be4501d12cb285 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Sat, 4 Apr 2026 16:53:35 -0700 Subject: [PATCH 4/6] fix: align JCS control character escapes --- .../extensions/src/offer-receipt/signing.ts | 20 ++++++- .../extensions/test/offer-receipt.test.ts | 3 ++ .../extensions/test/operation-binding.test.ts | 52 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/typescript/packages/extensions/src/offer-receipt/signing.ts b/typescript/packages/extensions/src/offer-receipt/signing.ts index 54d3864a4c..c84b71de5a 100644 --- a/typescript/packages/extensions/src/offer-receipt/signing.ts +++ b/typescript/packages/extensions/src/offer-receipt/signing.ts @@ -99,7 +99,25 @@ function serializeString(str: string): string { const char = str[i]; const code = str.charCodeAt(i); if (code < 0x20) { - result += "\\u" + code.toString(16).padStart(4, "0"); + switch (code) { + case 0x08: + result += "\\b"; + break; + case 0x09: + result += "\\t"; + break; + case 0x0a: + result += "\\n"; + break; + case 0x0c: + result += "\\f"; + break; + case 0x0d: + result += "\\r"; + break; + default: + result += "\\u" + code.toString(16).padStart(4, "0"); + } } else if (char === '"') { result += '\\"'; } else if (char === "\\") { diff --git a/typescript/packages/extensions/test/offer-receipt.test.ts b/typescript/packages/extensions/test/offer-receipt.test.ts index 504d98cfd7..43cf54626d 100644 --- a/typescript/packages/extensions/test/offer-receipt.test.ts +++ b/typescript/packages/extensions/test/offer-receipt.test.ts @@ -372,6 +372,9 @@ describe("x402 Offer/Receipt Extension", () => { it("handles -0 as 0", () => { expect(canonicalize({ n: -0 })).toBe('{"n":0}'); }); + it("uses short escape sequences for RFC 8785 control characters", () => { + expect(canonicalize({ s: "\b\f\n\r\t" })).toBe('{"s":"\\b\\f\\n\\r\\t"}'); + }); }); describe("Cryptographic Verification", () => { diff --git a/typescript/packages/extensions/test/operation-binding.test.ts b/typescript/packages/extensions/test/operation-binding.test.ts index 692e672ee2..81c63b1899 100644 --- a/typescript/packages/extensions/test/operation-binding.test.ts +++ b/typescript/packages/extensions/test/operation-binding.test.ts @@ -38,6 +38,20 @@ const WEATHER_BINDING: OperationBindingInfo = { bindBody: false, }; +const SEARCH_BINDING: OperationBindingInfo = { + transport: "http", + resourceUrl: "https://api.example.com/search", + method: "POST", + pathTemplate: "/search", + operationId: "search.run", + policyVersion: "2026-04-04", + canonicalization: "rfc8785-jcs", + digestAlgorithm: "sha-256", + bindPathParams: false, + bindQuery: false, + bindBody: true, +}; + describe("Operation-Binding Extension", () => { describe("declareOperationBindingExtension", () => { it("normalizes defaults for binding flags", () => { @@ -182,6 +196,44 @@ describe("Operation-Binding Extension", () => { expect(digestA).not.toBe(digestB); }); + + it("matches the RFC 8785 digest for a newline-containing body", async () => { + const digest = await computeOperationDigest(SEARCH_BINDING, { + body: { + query: "line1\nline2", + limit: 1, + }, + }); + + expect(digest).toBe("5caac7f75db73ddc5c662458aec0f25a58f50358c68c12a17cb7925f43291223"); + }); + + it("matches the RFC 8785 digest for tab and unicode content", async () => { + const digest = await computeOperationDigest(SEARCH_BINDING, { + body: { + q: "hello\tworld", + emoji: "cafΓ© 🐒", + }, + }); + + expect(digest).toBe("ac24f9d26314787e218aa0ae946e94a2be76e9cc63325afcca5e3c8b00a04333"); + }); + + it("matches the RFC 8785 digest when bindBody is disabled", async () => { + const digest = await computeOperationDigest( + { + ...SEARCH_BINDING, + bindBody: false, + }, + { + body: { + should: "be ignored", + }, + }, + ); + + expect(digest).toBe("85d5bd39278b4dda2e79ed21bf0dd21e58399fc17c019283ac939e5cc5bd096b"); + }); }); describe("receipt signing and verification", () => { From 5a833cab39196abe23223e06ac904a69a838ae3e Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Sat, 4 Apr 2026 18:52:14 -0700 Subject: [PATCH 5/6] test: add full operation-binding digest vectors --- .../extensions/test/operation-binding.test.ts | 146 ++++++++++++------ 1 file changed, 101 insertions(+), 45 deletions(-) diff --git a/typescript/packages/extensions/test/operation-binding.test.ts b/typescript/packages/extensions/test/operation-binding.test.ts index 81c63b1899..be9c1f6abb 100644 --- a/typescript/packages/extensions/test/operation-binding.test.ts +++ b/typescript/packages/extensions/test/operation-binding.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import type { PaymentRequired } from "@x402/core"; import { privateKeyToAccount } from "viem/accounts"; import type { Hex } from "viem"; import { OPERATION_BINDING, - operationBindingSchema, - declareOperationBindingExtension, - operationBindingResourceServerExtension, - createOperationBindingInput, - getOperationBindingCanonicalBytes, computeOperationDigest, - createOperationReceiptJWS, + createOperationBindingInput, createOperationReceiptEIP712, + createOperationReceiptJWS, + declareOperationBindingExtension, extractOperationReceiptPayload, - verifyOperationReceiptSignatureJWS, - verifyOperationReceiptSignatureEIP712, + getOperationBindingCanonicalBytes, + operationBindingResourceServerExtension, + operationBindingSchema, verifyOperationReceiptMatchesInput, + verifyOperationReceiptSignatureEIP712, + verifyOperationReceiptSignatureJWS, type OperationBindingInfo, } from "../src/operation-binding"; import { createES256KSigner, generateES256KKeyPair } from "./offer-receipt-test-utils"; @@ -52,6 +52,93 @@ const SEARCH_BINDING: OperationBindingInfo = { bindBody: true, }; +const OPERATION_DIGEST_VECTORS = [ + { + name: "matches the RFC 8785 digest for weather path and query input", + binding: WEATHER_BINDING, + components: { + pathParams: { city: "SF" }, + query: { units: "metric" }, + }, + digest: "fc54afe61f8ecb553a313c317bc31785def72ac348213718235077586956fd45", + }, + { + name: "matches the RFC 8785 digest for weather query expansion", + binding: WEATHER_BINDING, + components: { + pathParams: { city: "SF" }, + query: { units: "metric", lang: "en" }, + }, + digest: "263839b2849b379b304775feb8e88724ac5b0d7f3fb418a71b197ec4a4e35618", + }, + { + name: "matches the RFC 8785 digest for a basic search body", + binding: SEARCH_BINDING, + components: { + body: { + query: "x402", + limit: 10, + }, + }, + digest: "44bb1eb7c73f954faf13b4996eaeb0f1c2b98a9cc5c4e741c5e25d3d21dc0a11", + }, + { + name: "matches the RFC 8785 digest for a newline-containing body", + binding: SEARCH_BINDING, + components: { + body: { + query: "line1\nline2", + limit: 1, + }, + }, + digest: "5caac7f75db73ddc5c662458aec0f25a58f50358c68c12a17cb7925f43291223", + }, + { + name: "matches the RFC 8785 digest for tab and unicode content", + binding: SEARCH_BINDING, + components: { + body: { + q: "hello\tworld", + emoji: "caf\u00e9 \ud83d\udc22", + }, + }, + digest: "ac24f9d26314787e218aa0ae946e94a2be76e9cc63325afcca5e3c8b00a04333", + }, + { + name: "matches the RFC 8785 digest when bindBody is disabled", + binding: { + ...SEARCH_BINDING, + bindBody: false, + }, + components: { + body: { + should: "be ignored", + }, + }, + digest: "85d5bd39278b4dda2e79ed21bf0dd21e58399fc17c019283ac939e5cc5bd096b", + }, + { + name: "matches the RFC 8785 digest at the max safe integer boundary", + binding: SEARCH_BINDING, + components: { + body: { + max_safe: 9007199254740991, + }, + }, + digest: "a1b5ce6dfecedc5adc4793aa97acf61f85107c530bf850fb3331a76f4c071177", + }, + { + name: "matches the RFC 8785 digest when DEL is preserved in a string", + binding: SEARCH_BINDING, + components: { + body: { + s: "a\u007fb", + }, + }, + digest: "a73790954115b12634364b06ab3aeba5d0eaecffe0dced0daf15e9fc3f05bc9e", + }, +] as const; + describe("Operation-Binding Extension", () => { describe("declareOperationBindingExtension", () => { it("normalizes defaults for binding flags", () => { @@ -197,43 +284,12 @@ describe("Operation-Binding Extension", () => { expect(digestA).not.toBe(digestB); }); - it("matches the RFC 8785 digest for a newline-containing body", async () => { - const digest = await computeOperationDigest(SEARCH_BINDING, { - body: { - query: "line1\nline2", - limit: 1, - }, - }); - - expect(digest).toBe("5caac7f75db73ddc5c662458aec0f25a58f50358c68c12a17cb7925f43291223"); - }); - - it("matches the RFC 8785 digest for tab and unicode content", async () => { - const digest = await computeOperationDigest(SEARCH_BINDING, { - body: { - q: "hello\tworld", - emoji: "cafΓ© 🐒", - }, + for (const vector of OPERATION_DIGEST_VECTORS) { + it(vector.name, async () => { + const digest = await computeOperationDigest(vector.binding, vector.components); + expect(digest).toBe(vector.digest); }); - - expect(digest).toBe("ac24f9d26314787e218aa0ae946e94a2be76e9cc63325afcca5e3c8b00a04333"); - }); - - it("matches the RFC 8785 digest when bindBody is disabled", async () => { - const digest = await computeOperationDigest( - { - ...SEARCH_BINDING, - bindBody: false, - }, - { - body: { - should: "be ignored", - }, - }, - ); - - expect(digest).toBe("85d5bd39278b4dda2e79ed21bf0dd21e58399fc17c019283ac939e5cc5bd096b"); - }); + } }); describe("receipt signing and verification", () => { From 635341da00d34c25227a8df21a83df1c8e673747 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Sat, 4 Apr 2026 19:03:44 -0700 Subject: [PATCH 6/6] test: cover operation-binding http flows --- e2e/clients/fetch/index.ts | 28 +- e2e/clients/fetch/test.config.json | 1 + e2e/extensions/operation-binding.ts | 153 ++++ e2e/servers/express/index.ts | 119 ++- e2e/servers/express/test.config.json | 11 +- e2e/src/clients/generic-client.ts | 18 +- e2e/src/types.ts | 12 +- e2e/test.ts | 808 +++++++++++------- .../express/src/operation-binding.e2e.test.ts | 154 ++++ 9 files changed, 946 insertions(+), 358 deletions(-) create mode 100644 e2e/extensions/operation-binding.ts create mode 100644 typescript/packages/http/express/src/operation-binding.e2e.test.ts diff --git a/e2e/clients/fetch/index.ts b/e2e/clients/fetch/index.ts index e88320d3d5..0e806c245d 100644 --- a/e2e/clients/fetch/index.ts +++ b/e2e/clients/fetch/index.ts @@ -4,7 +4,10 @@ import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, baseSepolia } from "viem/chains"; import { ExactEvmScheme, type ExactEvmSchemeOptions } from "@x402/evm/exact/client"; -import { UptoEvmScheme as UptoEvmClientScheme, type UptoEvmSchemeOptions } from "@x402/evm/upto/client"; +import { + UptoEvmScheme as UptoEvmClientScheme, + type UptoEvmSchemeOptions, +} from "@x402/evm/upto/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -23,7 +26,9 @@ const baseURL = process.env.RESOURCE_SERVER_URL as string; const endpointPath = process.env.ENDPOINT_PATH as string; const url = `${baseURL}${endpointPath}`; const evmAccount = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`); -const svmSigner = await createKeyPairSignerFromBytes(base58.decode(process.env.SVM_PRIVATE_KEY as string)); +const svmSigner = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_PRIVATE_KEY as string), +); const evmNetwork = process.env.EVM_NETWORK || "eip155:84532"; const evmRpcUrl = process.env.EVM_RPC_URL; @@ -47,7 +52,10 @@ const uptoSchemeOptions: UptoEvmSchemeOptions | undefined = process.env.EVM_RPC_ // Initialize Aptos signer if key is provided let aptosAccount: Account | undefined; if (process.env.APTOS_PRIVATE_KEY) { - const formattedKey = PrivateKey.formatPrivateKey(process.env.APTOS_PRIVATE_KEY, PrivateKeyVariants.Ed25519); + const formattedKey = PrivateKey.formatPrivateKey( + process.env.APTOS_PRIVATE_KEY, + PrivateKeyVariants.Ed25519, + ); const aptosPrivateKey = new Ed25519PrivateKey(formattedKey); aptosAccount = Account.fromPrivateKey({ privateKey: aptosPrivateKey }); } @@ -73,13 +81,21 @@ if (stellarSigner) { client.register("stellar:*", new ExactStellarScheme(stellarSigner)); } -const fetchWithPayment = wrapFetchWithPayment(fetch, client); +let capturedPaymentRequired: unknown; + +const httpClient = new x402HTTPClient(client).onPaymentRequired(async ({ paymentRequired }) => { + capturedPaymentRequired = paymentRequired; +}); + +const fetchWithPayment = wrapFetchWithPayment(fetch, httpClient); fetchWithPayment(url, { method: "GET", }).then(async response => { const data = await response.json(); - const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse((name) => response.headers.get(name)); + const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name => + response.headers.get(name), + ); if (!paymentResponse) { // No payment was required @@ -87,6 +103,7 @@ fetchWithPayment(url, { success: true, data: data, status_code: response.status, + payment_required: capturedPaymentRequired, }; console.log(JSON.stringify(result)); process.exit(0); @@ -97,6 +114,7 @@ fetchWithPayment(url, { success: paymentResponse.success, data: data, status_code: response.status, + payment_required: capturedPaymentRequired, payment_response: paymentResponse, }; diff --git a/e2e/clients/fetch/test.config.json b/e2e/clients/fetch/test.config.json index 42b9be92a8..c18063b8c7 100644 --- a/e2e/clients/fetch/test.config.json +++ b/e2e/clients/fetch/test.config.json @@ -20,6 +20,7 @@ ] }, "extensions": [ + "operation-binding", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], diff --git a/e2e/extensions/operation-binding.ts b/e2e/extensions/operation-binding.ts new file mode 100644 index 0000000000..7b6c400d90 --- /dev/null +++ b/e2e/extensions/operation-binding.ts @@ -0,0 +1,153 @@ +import type { ScenarioResult, TestEndpoint, TestConfig } from "../src/types"; + +const OPERATION_BINDING = "operation-binding"; +const OPERATION_BINDING_PATH = "/exact/evm/operation-binding"; +const OPERATION_BINDING_OPERATION_ID = "exact.evm.operationBinding"; +const OPERATION_BINDING_POLICY_VERSION = "2026-04-04"; + +interface OperationBindingValidationResult { + success: boolean; + error?: string; +} + +/** + * Check whether the scenario endpoint declares the operation-binding extension. + * + * @param endpoint - Endpoint metadata from the E2E scenario. + * @returns `true` when the endpoint opts into operation-binding validation. + */ +function endpointRequiresOperationBinding(endpoint: TestEndpoint): boolean { + return endpoint.extensions?.includes(OPERATION_BINDING) ?? false; +} + +/** + * Check whether the client explicitly supports surfacing operation-binding details. + * + * @param clientConfig - E2E client configuration metadata. + * @returns `true` when the client can expose PaymentRequired extension data. + */ +export function clientSupportsOperationBinding( + clientConfig: TestConfig, +): boolean { + return clientConfig.extensions?.includes(OPERATION_BINDING) ?? false; +} + +/** + * Check whether a scenario should run operation-binding validation. + * + * @param selectedExtensions - Extension output flags selected for the run. + * @param endpoint - Endpoint metadata from the scenario. + * @param clientConfig - Client metadata from the scenario. + * @returns `true` when operation-binding should be validated for the scenario. + */ +export function shouldValidateOperationBinding( + selectedExtensions: string[] | undefined, + endpoint: TestEndpoint, + clientConfig: TestConfig, +): boolean { + if (!selectedExtensions?.includes(OPERATION_BINDING)) { + return false; + } + + if (!endpointRequiresOperationBinding(endpoint)) { + return false; + } + + return clientSupportsOperationBinding(clientConfig); +} + +/** + * Validate the operation-binding declaration captured from a live PaymentRequired response. + * + * @param result - Client result containing the captured PaymentRequired payload. + * @param endpoint - Endpoint metadata from the scenario. + * @returns Validation result. + */ +export function validateOperationBindingResult( + result: ScenarioResult, + endpoint: TestEndpoint, +): OperationBindingValidationResult { + const paymentRequired = result.payment_required; + if (!paymentRequired || typeof paymentRequired !== "object") { + return { + success: false, + error: + "Client did not capture the PaymentRequired payload for operation-binding validation.", + }; + } + + const extensions = ( + paymentRequired as { extensions?: Record } + ).extensions; + const extensionValue = extensions?.[OPERATION_BINDING]; + if (!extensionValue || typeof extensionValue !== "object") { + return { + success: false, + error: + "PaymentRequired response is missing the operation-binding extension.", + }; + } + + const info = (extensionValue as { info?: Record }).info; + if (!info || typeof info !== "object") { + return { + success: false, + error: "Operation-binding extension is missing its info payload.", + }; + } + + const expectedPath = endpoint.path.split("?")[0]; + + const expectedFields: Array<[keyof typeof info, unknown]> = [ + ["transport", "http"], + ["method", endpoint.method], + ["pathTemplate", expectedPath], + ["operationId", OPERATION_BINDING_OPERATION_ID], + ["policyVersion", OPERATION_BINDING_POLICY_VERSION], + ["canonicalization", "rfc8785-jcs"], + ["digestAlgorithm", "sha-256"], + ["bindPathParams", true], + ["bindQuery", true], + ["bindBody", false], + ]; + + for (const [field, expectedValue] of expectedFields) { + if (info[field] !== expectedValue) { + return { + success: false, + error: `Operation-binding field ${String(field)} was ${JSON.stringify(info[field])}, expected ${JSON.stringify(expectedValue)}.`, + }; + } + } + + const resourceUrl = info.resourceUrl; + if (typeof resourceUrl !== "string") { + return { + success: false, + error: "Operation-binding resourceUrl is missing or not a string.", + }; + } + + const url = new URL(resourceUrl); + if (url.pathname !== expectedPath) { + return { + success: false, + error: `Operation-binding resourceUrl pathname was ${url.pathname}, expected ${expectedPath}.`, + }; + } + + if (expectedPath === OPERATION_BINDING_PATH) { + if ( + url.searchParams.get("units") !== "metric" || + url.searchParams.get("lang") !== "en" + ) { + return { + success: false, + error: + "Operation-binding resourceUrl did not preserve the expected query parameters.", + }; + } + } + + return { success: true }; +} diff --git a/e2e/servers/express/index.ts b/e2e/servers/express/index.ts index 90591c19c9..18a70c9629 100644 --- a/e2e/servers/express/index.ts +++ b/e2e/servers/express/index.ts @@ -8,8 +8,11 @@ import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; import { bazaarResourceServerExtension, declareDiscoveryExtension } from "@x402/extensions/bazaar"; import { + OPERATION_BINDING, declareEip2612GasSponsoringExtension, declareErc20ApprovalGasSponsoringExtension, + declareOperationBindingExtension, + operationBindingResourceServerExtension, } from "@x402/extensions"; import dotenv from "dotenv"; @@ -76,6 +79,7 @@ if (STELLAR_PAYEE_ADDRESS) { // Register Bazaar discovery extension server.registerExtension(bazaarResourceServerExtension); +server.registerExtension(operationBindingResourceServerExtension); console.log( `Facilitator account: ${process.env.EVM_PRIVATE_KEY ? process.env.EVM_PRIVATE_KEY.substring(0, 10) + "..." : "not configured"}`, @@ -145,6 +149,21 @@ app.use( }), }, }, + "GET /exact/evm/operation-binding": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: EVM_NETWORK, + }, + extensions: { + [OPERATION_BINDING]: declareOperationBindingExtension({ + operationId: "exact.evm.operationBinding", + policyVersion: "2026-04-04", + bindBody: false, + }), + }, + }, "GET /exact/svm": { accepts: { payTo: SVM_PAYEE_ADDRESS, @@ -172,32 +191,32 @@ app.use( }, ...(APTOS_PAYEE_ADDRESS ? { - "GET /exact/aptos": { - accepts: { - payTo: APTOS_PAYEE_ADDRESS, - scheme: "exact", - price: "$0.001", - network: APTOS_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, + "GET /exact/aptos": { + accepts: { + payTo: APTOS_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: APTOS_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, + }, + required: ["message", "timestamp"], }, - required: ["message", "timestamp"], }, - }, - }), + }), + }, }, - }, - } + } : {}), // Permit2 endpoint for ERC-20 approval gas sponsoring (no EIP-2612) "GET /exact/evm/permit2-erc20ApprovalGasSponsoring": { @@ -343,32 +362,32 @@ app.use( }, ...(STELLAR_PAYEE_ADDRESS ? { - "GET /exact/stellar": { - accepts: { - payTo: STELLAR_PAYEE_ADDRESS!, - scheme: "exact", - price: "$0.001", - network: STELLAR_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected Stellar endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, + "GET /exact/stellar": { + accepts: { + payTo: STELLAR_PAYEE_ADDRESS!, + scheme: "exact", + price: "$0.001", + network: STELLAR_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected Stellar endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, + }, + required: ["message", "timestamp"], }, - required: ["message", "timestamp"], }, - }, - }), + }), + }, }, - }, - } + } : {}), }, server, // Pass pre-configured server instance @@ -388,6 +407,14 @@ app.get("/exact/evm/eip3009", (req, res) => { }); }); +app.get("/exact/evm/operation-binding", (req, res) => { + res.json({ + message: "Operation-binding endpoint accessed successfully", + timestamp: new Date().toISOString(), + query: req.query, + }); +}); + /** * Protected SVM endpoint - requires payment to access * diff --git a/e2e/servers/express/test.config.json b/e2e/servers/express/test.config.json index 188ab91bbf..64845b1d4a 100644 --- a/e2e/servers/express/test.config.json +++ b/e2e/servers/express/test.config.json @@ -3,7 +3,7 @@ "type": "server", "language": "typescript", "x402Version": 2, - "extensions": ["bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring"], + "extensions": ["bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring", "operation-binding"], "endpoints": [ { @@ -14,6 +14,15 @@ "protocolFamily": "evm", "transferMethod": "eip3009" }, + { + "path": "/exact/evm/operation-binding?units=metric&lang=en", + "method": "GET", + "description": "Protected endpoint requiring EIP-3009 payment with operation-binding metadata", + "requiresPayment": true, + "protocolFamily": "evm", + "transferMethod": "eip3009", + "extensions": ["operation-binding"] + }, { "path": "/exact/evm/permit2", "method": "GET", diff --git a/e2e/src/clients/generic-client.ts b/e2e/src/clients/generic-client.ts index aff2ed44a0..5753f05cdc 100644 --- a/e2e/src/clients/generic-client.ts +++ b/e2e/src/clients/generic-client.ts @@ -1,10 +1,11 @@ -import { BaseProxy, RunConfig } from '../proxy-base'; -import { ClientConfig, ClientProxy } from '../types'; +import { BaseProxy, RunConfig } from "../proxy-base"; +import { ClientConfig, ClientProxy } from "../types"; export interface ClientCallResult { success: boolean; data?: any; status_code?: number; + payment_required?: any; payment_response?: any; error?: string; exitCode?: number; @@ -13,7 +14,7 @@ export interface ClientCallResult { export class GenericClientProxy extends BaseProxy implements ClientProxy { constructor(directory: string) { // For clients, we don't wait for a ready log since they're one-shot processes - super(directory, ''); + super(directory, ""); } async call(config: ClientConfig): Promise { @@ -28,7 +29,7 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { ENDPOINT_PATH: config.endpointPath, EVM_NETWORK: config.evmNetwork, EVM_RPC_URL: config.evmRpcUrl, - } + }, }; // For clients, we run the process and wait for it to complete @@ -40,20 +41,21 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { success: true, data: result.data.data, status_code: result.data.status_code, + payment_required: result.data.payment_required, payment_response: result.data.payment_response, - exitCode: result.exitCode + exitCode: result.exitCode, }; } else { return { success: false, error: result.error, - exitCode: result.exitCode + exitCode: result.exitCode, }; } } catch (error) { return { success: false, - error: error instanceof Error ? error.message : String(error) + error: error instanceof Error ? error.message : String(error), }; } } @@ -71,4 +73,4 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { async forceStop(): Promise { await this.stopProcess(); } -} \ No newline at end of file +} diff --git a/e2e/src/types.ts b/e2e/src/types.ts index 841f200c39..385675fe51 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -1,13 +1,14 @@ -import type { NetworkSet } from './networks/networks'; +import type { NetworkSet } from "./networks/networks"; -export type ProtocolFamily = 'evm' | 'svm' | 'aptos' | 'stellar'; -export type Transport = 'http' | 'mcp'; -export type TransferMethod = 'eip3009' | 'permit2' | 'upto'; +export type ProtocolFamily = "evm" | "svm" | "aptos" | "stellar"; +export type Transport = "http" | "mcp"; +export type TransferMethod = "eip3009" | "permit2" | "upto"; export interface ClientResult { success: boolean; data?: any; status_code?: number; + payment_required?: any; payment_response?: any; error?: string; } @@ -64,7 +65,7 @@ export interface TestEndpoint { export interface TestConfig { name: string; - type: 'server' | 'client' | 'facilitator'; + type: "server" | "client" | "facilitator"; transport?: Transport; language: string; protocolFamilies?: ProtocolFamily[]; @@ -127,5 +128,6 @@ export interface ScenarioResult { error?: string; data?: any; status_code?: number; + payment_required?: any; payment_response?: any; } diff --git a/e2e/test.ts b/e2e/test.ts index 4349597506..5624f894c0 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -1,27 +1,61 @@ -import { config } from 'dotenv'; -import { spawn, execSync, ChildProcess } from 'child_process'; -import { writeFileSync } from 'fs'; -import { join } from 'path'; -import { createWalletClient, createPublicClient, http, parseEther, formatEther } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; -import { base, baseSepolia } from 'viem/chains'; -import { TestDiscovery } from './src/discovery'; -import { ClientConfig, ScenarioResult, ServerConfig, TestScenario } from './src/types'; -import { config as loggerConfig, log, verboseLog, errorLog, close as closeLogger, createComboLogger } from './src/logger'; -import { handleDiscoveryValidation, shouldRunDiscoveryValidation } from './extensions/bazaar'; -import { parseArgs, printHelp } from './src/cli/args'; -import { runInteractiveMode } from './src/cli/interactive'; -import { filterScenarios, TestFilters, shouldShowExtensionOutput } from './src/cli/filters'; -import { minimizeScenarios } from './src/sampling'; -import { getNetworkSet, NetworkMode, NetworkSet, getNetworkModeDescription } from './src/networks/networks'; -import { GenericServerProxy } from './src/servers/generic-server'; -import { Semaphore, FacilitatorLock } from './src/concurrency'; -import { FacilitatorManager } from './src/facilitators/facilitator-manager'; -import { waitForHealth } from './src/health'; +import { config } from "dotenv"; +import { spawn, execSync, ChildProcess } from "child_process"; +import { writeFileSync } from "fs"; +import { join } from "path"; +import { + createWalletClient, + createPublicClient, + http, + parseEther, + formatEther, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base, baseSepolia } from "viem/chains"; +import { TestDiscovery } from "./src/discovery"; +import { + ClientConfig, + ScenarioResult, + ServerConfig, + TestScenario, +} from "./src/types"; +import { + config as loggerConfig, + log, + verboseLog, + errorLog, + close as closeLogger, + createComboLogger, +} from "./src/logger"; +import { + handleDiscoveryValidation, + shouldRunDiscoveryValidation, +} from "./extensions/bazaar"; +import { + shouldValidateOperationBinding, + validateOperationBindingResult, +} from "./extensions/operation-binding"; +import { parseArgs, printHelp } from "./src/cli/args"; +import { runInteractiveMode } from "./src/cli/interactive"; +import { + filterScenarios, + TestFilters, + shouldShowExtensionOutput, +} from "./src/cli/filters"; +import { minimizeScenarios } from "./src/sampling"; +import { + getNetworkSet, + NetworkMode, + NetworkSet, + getNetworkModeDescription, +} from "./src/networks/networks"; +import { GenericServerProxy } from "./src/servers/generic-server"; +import { Semaphore, FacilitatorLock } from "./src/concurrency"; +import { FacilitatorManager } from "./src/facilitators/facilitator-manager"; +import { waitForHealth } from "./src/health"; // Base Sepolia token addresses used by permit2 E2E tests -const USDC_BASE_SEPOLIA = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; -const MOCK_ERC20_BASE_SEPOLIA = '0xeED520980fC7C7B4eB379B96d61CEdea2423005a'; +const USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const MOCK_ERC20_BASE_SEPOLIA = "0xeED520980fC7C7B4eB379B96d61CEdea2423005a"; /** * Approve Permit2 so that the standard/direct settle path can be exercised. @@ -29,33 +63,33 @@ const MOCK_ERC20_BASE_SEPOLIA = '0xeED520980fC7C7B4eB379B96d61CEdea2423005a'; */ async function approvePermit2Approval(tokenAddress?: string): Promise { return new Promise((resolve) => { - const label = tokenAddress ? `token ${tokenAddress}` : 'USDC (default)'; + const label = tokenAddress ? `token ${tokenAddress}` : "USDC (default)"; verboseLog(` πŸ”“ Approving Permit2 for ${label}...`); - const args = ['scripts/permit2-approval.ts', 'approve']; + const args = ["scripts/permit2-approval.ts", "approve"]; if (tokenAddress) { args.push(tokenAddress); } - const child = spawn('tsx', args, { + const child = spawn("tsx", args, { cwd: process.cwd(), - stdio: 'pipe', + stdio: "pipe", shell: true, }); - let stderr = ''; + let stderr = ""; - child.stdout?.on('data', (data) => { + child.stdout?.on("data", (data) => { verboseLog(data.toString().trim()); }); - child.stderr?.on('data', (data) => { + child.stderr?.on("data", (data) => { stderr += data.toString(); verboseLog(data.toString().trim()); }); - child.on('close', (code) => { + child.on("close", (code) => { if (code === 0) { - verboseLog(' βœ… Permit2 approval granted'); + verboseLog(" βœ… Permit2 approval granted"); resolve(true); } else { errorLog(` ❌ Permit2 approve failed (exit code ${code})`); @@ -66,7 +100,7 @@ async function approvePermit2Approval(tokenAddress?: string): Promise { } }); - child.on('error', (error) => { + child.on("error", (error) => { errorLog(` ❌ Failed to run Permit2 approve: ${error.message}`); resolve(false); }); @@ -80,33 +114,33 @@ async function approvePermit2Approval(tokenAddress?: string): Promise { */ async function revokePermit2Approval(tokenAddress?: string): Promise { return new Promise((resolve) => { - const label = tokenAddress ? `token ${tokenAddress}` : 'USDC (default)'; + const label = tokenAddress ? `token ${tokenAddress}` : "USDC (default)"; verboseLog(` πŸ”“ Revoking Permit2 approval for ${label}...`); - const args = ['scripts/permit2-approval.ts', 'revoke']; + const args = ["scripts/permit2-approval.ts", "revoke"]; if (tokenAddress) { args.push(tokenAddress); } - const child = spawn('tsx', args, { + const child = spawn("tsx", args, { cwd: process.cwd(), - stdio: 'pipe', + stdio: "pipe", shell: true, }); - let stderr = ''; + let stderr = ""; - child.stdout?.on('data', (data) => { + child.stdout?.on("data", (data) => { verboseLog(data.toString().trim()); }); - child.stderr?.on('data', (data) => { + child.stderr?.on("data", (data) => { stderr += data.toString(); verboseLog(data.toString().trim()); }); - child.on('close', (code) => { + child.on("close", (code) => { if (code === 0) { - verboseLog(' βœ… Permit2 approval revoked (allowance set to 0)'); + verboseLog(" βœ… Permit2 approval revoked (allowance set to 0)"); resolve(true); } else { errorLog(` ❌ Permit2 revoke failed (exit code ${code})`); @@ -117,7 +151,7 @@ async function revokePermit2Approval(tokenAddress?: string): Promise { } }); - child.on('error', (error) => { + child.on("error", (error) => { errorLog(` ❌ Failed to run Permit2 revoke: ${error.message}`); resolve(false); }); @@ -130,17 +164,21 @@ async function revokePermit2Approval(tokenAddress?: string): Promise { * non-EVM test runs. */ function getEvmClients() { - const evmNetwork = process.env.EVM_NETWORK || 'eip155:84532'; + const evmNetwork = process.env.EVM_NETWORK || "eip155:84532"; const evmRpcUrl = process.env.EVM_RPC_URL; - const evmChain = evmNetwork === 'eip155:8453' ? base : baseSepolia; + const evmChain = evmNetwork === "eip155:8453" ? base : baseSepolia; const facilitatorKey = process.env.FACILITATOR_EVM_PRIVATE_KEY; const clientKey = process.env.CLIENT_EVM_PRIVATE_KEY; if (!facilitatorKey || !clientKey) { - throw new Error('FACILITATOR_EVM_PRIVATE_KEY and CLIENT_EVM_PRIVATE_KEY must be set'); + throw new Error( + "FACILITATOR_EVM_PRIVATE_KEY and CLIENT_EVM_PRIVATE_KEY must be set", + ); } - const facilitatorAccount = privateKeyToAccount(facilitatorKey as `0x${string}`); + const facilitatorAccount = privateKeyToAccount( + facilitatorKey as `0x${string}`, + ); const clientAccount = privateKeyToAccount(clientKey as `0x${string}`); const publicClient = createPublicClient({ @@ -158,42 +196,61 @@ function getEvmClients() { transport: http(evmRpcUrl), }); - return { publicClient, facilitatorWallet, clientWallet, facilitatorAccount, clientAccount }; + return { + publicClient, + facilitatorWallet, + clientWallet, + facilitatorAccount, + clientAccount, + }; } -const REVOKE_FUND_AMOUNT = parseEther('0.001'); +const REVOKE_FUND_AMOUNT = parseEther("0.001"); /** * Send a small amount of ETH from the facilitator wallet to the client wallet * so the client can pay gas for Permit2 revocation transactions. */ async function fundClientForRevoke(): Promise { - const { publicClient, facilitatorWallet, facilitatorAccount, clientAccount } = getEvmClients(); + const { publicClient, facilitatorWallet, facilitatorAccount, clientAccount } = + getEvmClients(); - const clientBalance = await publicClient.getBalance({ address: clientAccount.address }); + const clientBalance = await publicClient.getBalance({ + address: clientAccount.address, + }); if (clientBalance >= REVOKE_FUND_AMOUNT) { - verboseLog(` ℹ️ Client already has ${formatEther(clientBalance)} ETH, skipping fund`); + verboseLog( + ` ℹ️ Client already has ${formatEther(clientBalance)} ETH, skipping fund`, + ); return true; } - const facilitatorBalance = await publicClient.getBalance({ address: facilitatorAccount.address }); + const facilitatorBalance = await publicClient.getBalance({ + address: facilitatorAccount.address, + }); if (facilitatorBalance < REVOKE_FUND_AMOUNT) { - errorLog(` ❌ Facilitator wallet ${facilitatorAccount.address} has insufficient ETH (${formatEther(facilitatorBalance)}) to fund client for revoke.`); - errorLog(` Please fund the facilitator wallet with testnet ETH (need at least ${formatEther(REVOKE_FUND_AMOUNT)} ETH).`); + errorLog( + ` ❌ Facilitator wallet ${facilitatorAccount.address} has insufficient ETH (${formatEther(facilitatorBalance)}) to fund client for revoke.`, + ); + errorLog( + ` Please fund the facilitator wallet with testnet ETH (need at least ${formatEther(REVOKE_FUND_AMOUNT)} ETH).`, + ); return false; } - verboseLog(` πŸ’Έ Funding client ${clientAccount.address} with ${formatEther(REVOKE_FUND_AMOUNT)} ETH for revoke...`); + verboseLog( + ` πŸ’Έ Funding client ${clientAccount.address} with ${formatEther(REVOKE_FUND_AMOUNT)} ETH for revoke...`, + ); // Retry on nonce errors: load-balanced RPCs can return stale pending nonces, // especially when the facilitator SERVICE process (same private key) is settling // payments concurrently. A fresh nonce fetch + small delay usually resolves it. let lastErr: Error | null = null; for (let attempt = 0; attempt < 3; attempt++) { - if (attempt > 0) await new Promise(r => setTimeout(r, 500)); + if (attempt > 0) await new Promise((r) => setTimeout(r, 500)); try { const nonce = await publicClient.getTransactionCount({ address: facilitatorAccount.address, - blockTag: 'pending', + blockTag: "pending", }); const hash = await facilitatorWallet.sendTransaction({ to: clientAccount.address, @@ -204,13 +261,13 @@ async function fundClientForRevoke(): Promise { return true; } catch (err) { lastErr = err instanceof Error ? err : new Error(String(err)); - const isNonceError = lastErr.message.toLowerCase().includes('nonce'); + const isNonceError = lastErr.message.toLowerCase().includes("nonce"); if (!isNonceError) break; } } - const errLines = lastErr!.message.split('\n'); + const errLines = lastErr!.message.split("\n"); errorLog(` ❌ Failed to fund client for revoke: ${errLines[0].trim()}`); - if (errLines.length > 1) verboseLog(errLines.slice(1).join('\n')); + if (errLines.length > 1) verboseLog(errLines.slice(1).join("\n")); return false; } @@ -221,24 +278,32 @@ async function fundClientForRevoke(): Promise { */ async function drainClientETH(): Promise { try { - const { publicClient, clientWallet, facilitatorAccount, clientAccount } = getEvmClients(); + const { publicClient, clientWallet, facilitatorAccount, clientAccount } = + getEvmClients(); // Use pending balance so we see any in-flight fund transaction that hasn't confirmed yet. - const balance = await publicClient.getBalance({ address: clientAccount.address, blockTag: 'pending' }); + const balance = await publicClient.getBalance({ + address: clientAccount.address, + blockTag: "pending", + }); // Reserve enough for gas. On L2s getGasPrice() returns a tiny value but // viem's sendTransaction uses a higher maxFeePerGas with safety margin. // Use a generous fixed buffer to avoid "insufficient funds" from the // estimateGas pre-check. - const GAS_RESERVE = parseEther('0.0001'); + const GAS_RESERVE = parseEther("0.0001"); const sendAmount = balance - GAS_RESERVE; if (sendAmount <= 0n) { - verboseLog(` ℹ️ Client balance (${formatEther(balance)} ETH) too small to drain, leaving as dust`); + verboseLog( + ` ℹ️ Client balance (${formatEther(balance)} ETH) too small to drain, leaving as dust`, + ); return true; } - verboseLog(` πŸ’Έ Draining ${formatEther(sendAmount)} ETH from client back to facilitator...`); + verboseLog( + ` πŸ’Έ Draining ${formatEther(sendAmount)} ETH from client back to facilitator...`, + ); const hash = await clientWallet.sendTransaction({ to: facilitatorAccount.address, value: sendAmount, @@ -246,7 +311,9 @@ async function drainClientETH(): Promise { verboseLog(` βœ… Drained client ETH (tx: ${hash})`); return true; } catch (err) { - errorLog(` ❌ Failed to drain client ETH: ${err instanceof Error ? err.message : err}`); + errorLog( + ` ❌ Failed to drain client ETH: ${err instanceof Error ? err.message : err}`, + ); return false; } } @@ -259,20 +326,20 @@ const parsedArgs = parseArgs(); async function startServer( server: any, - serverConfig: ServerConfig + serverConfig: ServerConfig, ): Promise { verboseLog(` πŸš€ Starting server on port ${serverConfig.port}...`); await server.start(serverConfig); - return waitForHealth( - () => server.health(), - { initialDelayMs: 250, label: 'Server' }, - ); + return waitForHealth(() => server.health(), { + initialDelayMs: 250, + label: "Server", + }); } async function runClientTest( client: any, - callConfig: ClientConfig + callConfig: ClientConfig, ): Promise { const verboseLogs: string[] = []; @@ -289,21 +356,23 @@ async function runClientTest( if (!result.success) { return { success: false, - error: result.error || 'Client execution failed', - verboseLogs + error: result.error || "Client execution failed", + payment_required: result.payment_required, + verboseLogs, }; } // Check if we got a 402 Payment Required response (payment failed) if (result.status_code === 402) { const errorData = result.data as any; - const errorMsg = errorData?.error || 'Payment required - payment failed'; + const errorMsg = errorData?.error || "Payment required - payment failed"; return { success: false, error: `Payment failed (402): ${errorMsg}`, data: result.data, status_code: result.status_code, - verboseLogs + payment_required: result.payment_required, + verboseLogs, }; } @@ -314,11 +383,12 @@ async function runClientTest( if (!paymentResponse.success) { return { success: false, - error: `Payment failed: ${paymentResponse.errorReason || 'unknown error'}`, + error: `Payment failed: ${paymentResponse.errorReason || "unknown error"}`, data: result.data, status_code: result.status_code, + payment_required: result.payment_required, payment_response: paymentResponse, - verboseLogs + verboseLogs, }; } @@ -326,11 +396,12 @@ async function runClientTest( if (!paymentResponse.transaction) { return { success: false, - error: 'Payment succeeded but no transaction hash returned', + error: "Payment succeeded but no transaction hash returned", data: result.data, status_code: result.status_code, + payment_required: result.payment_required, payment_response: paymentResponse, - verboseLogs + verboseLogs, }; } @@ -341,8 +412,9 @@ async function runClientTest( error: `Payment has error reason: ${paymentResponse.errorReason}`, data: result.data, status_code: result.status_code, + payment_required: result.payment_required, payment_response: paymentResponse, - verboseLogs + verboseLogs, }; } } @@ -352,15 +424,16 @@ async function runClientTest( success: true, data: result.data, status_code: result.status_code, + payment_required: result.payment_required, payment_response: paymentResponse, - verboseLogs + verboseLogs, }; } catch (error) { bufferLog(` πŸ’₯ Client failed: ${error}`); return { success: false, error: error instanceof Error ? error.message : String(error), - verboseLogs + verboseLogs, }; } finally { await client.forceStop(); @@ -377,8 +450,8 @@ async function runTest() { // Initialize logger loggerConfig({ logFile: parsedArgs.logFile, verbose: parsedArgs.verbose }); - log('πŸš€ Starting X402 E2E Test Suite'); - log('==============================='); + log("πŸš€ Starting X402 E2E Test Suite"); + log("==============================="); // Load configuration from environment const serverEvmAddress = process.env.SERVER_EVM_ADDRESS; @@ -392,15 +465,25 @@ async function runTest() { const facilitatorEvmPrivateKey = process.env.FACILITATOR_EVM_PRIVATE_KEY; const facilitatorSvmPrivateKey = process.env.FACILITATOR_SVM_PRIVATE_KEY; const facilitatorAptosPrivateKey = process.env.FACILITATOR_APTOS_PRIVATE_KEY; - const facilitatorStellarPrivateKey = process.env.FACILITATOR_STELLAR_PRIVATE_KEY; - if (!serverEvmAddress || !serverSvmAddress || !clientEvmPrivateKey || !clientSvmPrivateKey || !facilitatorEvmPrivateKey || !facilitatorSvmPrivateKey) { - errorLog('❌ Missing required environment variables:'); - errorLog(' SERVER_EVM_ADDRESS, SERVER_SVM_ADDRESS, CLIENT_EVM_PRIVATE_KEY, CLIENT_SVM_PRIVATE_KEY, FACILITATOR_EVM_PRIVATE_KEY, and FACILITATOR_SVM_PRIVATE_KEY must be set'); + const facilitatorStellarPrivateKey = + process.env.FACILITATOR_STELLAR_PRIVATE_KEY; + if ( + !serverEvmAddress || + !serverSvmAddress || + !clientEvmPrivateKey || + !clientSvmPrivateKey || + !facilitatorEvmPrivateKey || + !facilitatorSvmPrivateKey + ) { + errorLog("❌ Missing required environment variables:"); + errorLog( + " SERVER_EVM_ADDRESS, SERVER_SVM_ADDRESS, CLIENT_EVM_PRIVATE_KEY, CLIENT_SVM_PRIVATE_KEY, FACILITATOR_EVM_PRIVATE_KEY, and FACILITATOR_SVM_PRIVATE_KEY must be set", + ); process.exit(1); } // Discover all servers, clients, and facilitators (always include legacy) - const discovery = new TestDiscovery('.', true); // Always discover legacy + const discovery = new TestDiscovery(".", true); // Always discover legacy const allClients = discovery.discoverClients(); const allServers = discovery.discoverServers(); @@ -412,7 +495,7 @@ async function runTest() { const allScenarios = discovery.generateTestScenarios(); if (allScenarios.length === 0) { - log('❌ No test scenarios found'); + log("❌ No test scenarios found"); return; } @@ -421,18 +504,18 @@ async function runTest() { let networkMode: NetworkMode; // Interactive or programmatic mode - if (parsedArgs.mode === 'interactive') { + if (parsedArgs.mode === "interactive") { const selections = await runInteractiveMode( allClients, allServers, allFacilitators, allScenarios, parsedArgs.minimize, - parsedArgs.networkMode // Pass preselected network mode (may be undefined) + parsedArgs.networkMode, // Pass preselected network mode (may be undefined) ); if (!selections) { - log('\n❌ Cancelled by user'); + log("\n❌ Cancelled by user"); return; } @@ -440,25 +523,27 @@ async function runTest() { selectedExtensions = selections.extensions; networkMode = selections.networkMode; } else { - log('\nπŸ€– Programmatic Mode'); - log('===================\n'); + log("\nπŸ€– Programmatic Mode"); + log("===================\n"); filters = parsedArgs.filters; selectedExtensions = parsedArgs.filters.extensions; // In programmatic mode, network mode defaults to testnet if not specified - networkMode = parsedArgs.networkMode || 'testnet'; + networkMode = parsedArgs.networkMode || "testnet"; // Print active filters - const filterEntries = Object.entries(filters).filter(([_, v]) => v && (Array.isArray(v) ? v.length > 0 : true)); + const filterEntries = Object.entries(filters).filter( + ([_, v]) => v && (Array.isArray(v) ? v.length > 0 : true), + ); if (filterEntries.length > 0) { - log('Active filters:'); + log("Active filters:"); filterEntries.forEach(([key, value]) => { if (Array.isArray(value) && value.length > 0) { - log(` - ${key}: ${value.join(', ')}`); + log(` - ${key}: ${value.join(", ")}`); } }); - log(''); + log(""); } } @@ -471,17 +556,17 @@ async function runTest() { log(` APTOS: ${networks.aptos.name} (${networks.aptos.caip2})`); log(` STELLAR: ${networks.stellar.name} (${networks.stellar.caip2})`); - if (networkMode === 'mainnet') { - log('\n⚠️ WARNING: Running on MAINNET - real funds will be used!'); + if (networkMode === "mainnet") { + log("\n⚠️ WARNING: Running on MAINNET - real funds will be used!"); } - log(''); + log(""); // Apply filters to scenarios let filteredScenarios = filterScenarios(allScenarios, filters); if (filteredScenarios.length === 0) { - log('❌ No scenarios match the selections'); - log('πŸ’‘ Try selecting more options or run without filters\n'); + log("❌ No scenarios match the selections"); + log("πŸ’‘ Try selecting more options or run without filters\n"); return; } @@ -490,8 +575,8 @@ async function runTest() { filteredScenarios = minimizeScenarios(filteredScenarios); if (filteredScenarios.length === 0) { - log('❌ All scenarios are already covered'); - log('πŸ’‘ This should not happen - coverage tracking may have an issue\n'); + log("❌ All scenarios are already covered"); + log("πŸ’‘ This should not happen - coverage tracking may have an issue\n"); return; } } else { @@ -499,51 +584,99 @@ async function runTest() { } if (selectedExtensions && selectedExtensions.length > 0) { - log(`🎁 Extensions enabled: ${selectedExtensions.join(', ')}`); + log(`🎁 Extensions enabled: ${selectedExtensions.join(", ")}`); } - log(''); + log(""); // Branch coverage assertions for EVM scenarios - const evmScenarios = filteredScenarios.filter(s => s.protocolFamily === 'evm'); + const evmScenarios = filteredScenarios.filter( + (s) => s.protocolFamily === "evm", + ); if (evmScenarios.length > 0) { - const hasEip3009 = evmScenarios.some(s => (s.endpoint.transferMethod || 'eip3009') === 'eip3009'); - const hasPermit2 = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2'); - const hasPermit2Direct = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2' && s.endpoint.permit2Direct === true); - const hasPermit2Eip2612 = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2' && !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && !s.endpoint.permit2Direct); - const hasPermit2Erc20 = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2' && s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring')); - - const hasUpto = evmScenarios.some(s => s.endpoint.transferMethod === 'upto'); - const hasUptoDirect = evmScenarios.some(s => s.endpoint.transferMethod === 'upto' && s.endpoint.permit2Direct === true); - const hasUptoEip2612 = evmScenarios.some(s => s.endpoint.transferMethod === 'upto' && !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && !s.endpoint.permit2Direct); - const hasUptoErc20 = evmScenarios.some(s => s.endpoint.transferMethod === 'upto' && s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring')); - - log('πŸ” EVM Branch Coverage Check:'); - log(` EIP-3009 route: ${hasEip3009 ? 'βœ…' : '❌ MISSING'}`); - log(` Permit2 route: ${hasPermit2 ? 'βœ…' : '❌ MISSING'}`); - log(` Permit2+direct settle: ${hasPermit2Direct ? 'βœ…' : '⚠️ not found'}`); - log(` Permit2+EIP2612 route: ${hasPermit2Eip2612 ? 'βœ…' : '⚠️ not found (may be covered by permit2 route if eip2612 extension enabled)'}`); - log(` Permit2+ERC20 route: ${hasPermit2Erc20 ? 'βœ…' : '⚠️ not found'}`); - log(` Upto route: ${hasUpto ? 'βœ…' : '⚠️ not found'}`); - log(` Upto+direct settle: ${hasUptoDirect ? 'βœ…' : '⚠️ not found'}`); - log(` Upto+EIP2612 route: ${hasUptoEip2612 ? 'βœ…' : '⚠️ not found'}`); - log(` Upto+ERC20 route: ${hasUptoErc20 ? 'βœ…' : '⚠️ not found'}`); - log(''); + const hasEip3009 = evmScenarios.some( + (s) => (s.endpoint.transferMethod || "eip3009") === "eip3009", + ); + const hasPermit2 = evmScenarios.some( + (s) => s.endpoint.transferMethod === "permit2", + ); + const hasPermit2Direct = evmScenarios.some( + (s) => + s.endpoint.transferMethod === "permit2" && + s.endpoint.permit2Direct === true, + ); + const hasPermit2Eip2612 = evmScenarios.some( + (s) => + s.endpoint.transferMethod === "permit2" && + !s.endpoint.extensions?.includes("erc20ApprovalGasSponsoring") && + !s.endpoint.permit2Direct, + ); + const hasPermit2Erc20 = evmScenarios.some( + (s) => + s.endpoint.transferMethod === "permit2" && + s.endpoint.extensions?.includes("erc20ApprovalGasSponsoring"), + ); + + const hasUpto = evmScenarios.some( + (s) => s.endpoint.transferMethod === "upto", + ); + const hasUptoDirect = evmScenarios.some( + (s) => + s.endpoint.transferMethod === "upto" && + s.endpoint.permit2Direct === true, + ); + const hasUptoEip2612 = evmScenarios.some( + (s) => + s.endpoint.transferMethod === "upto" && + !s.endpoint.extensions?.includes("erc20ApprovalGasSponsoring") && + !s.endpoint.permit2Direct, + ); + const hasUptoErc20 = evmScenarios.some( + (s) => + s.endpoint.transferMethod === "upto" && + s.endpoint.extensions?.includes("erc20ApprovalGasSponsoring"), + ); + + log("πŸ” EVM Branch Coverage Check:"); + log(` EIP-3009 route: ${hasEip3009 ? "βœ…" : "❌ MISSING"}`); + log(` Permit2 route: ${hasPermit2 ? "βœ…" : "❌ MISSING"}`); + log( + ` Permit2+direct settle: ${hasPermit2Direct ? "βœ…" : "⚠️ not found"}`, + ); + log( + ` Permit2+EIP2612 route: ${hasPermit2Eip2612 ? "βœ…" : "⚠️ not found (may be covered by permit2 route if eip2612 extension enabled)"}`, + ); + log( + ` Permit2+ERC20 route: ${hasPermit2Erc20 ? "βœ…" : "⚠️ not found"}`, + ); + log(` Upto route: ${hasUpto ? "βœ…" : "⚠️ not found"}`); + log( + ` Upto+direct settle: ${hasUptoDirect ? "βœ…" : "⚠️ not found"}`, + ); + log( + ` Upto+EIP2612 route: ${hasUptoEip2612 ? "βœ…" : "⚠️ not found"}`, + ); + log(` Upto+ERC20 route: ${hasUptoErc20 ? "βœ…" : "⚠️ not found"}`); + log(""); } // Auto-detect Permit2 scenarios (upto uses Permit2 under the hood) const hasPermit2Scenarios = filteredScenarios.some( - (s) => s.endpoint.transferMethod === 'permit2' || s.endpoint.transferMethod === 'upto' + (s) => + s.endpoint.transferMethod === "permit2" || + s.endpoint.transferMethod === "upto", ); if (hasPermit2Scenarios) { - log('πŸ” Permit2 scenarios detected β€” revoke before gas-sponsored tests, approve before permit2-direct tests'); + log( + "πŸ” Permit2 scenarios detected β€” revoke before gas-sponsored tests, approve before permit2-direct tests", + ); } // Collect unique facilitators and servers const uniqueFacilitators = new Map(); const uniqueServers = new Map(); - filteredScenarios.forEach(scenario => { + filteredScenarios.forEach((scenario) => { if (scenario.facilitator) { uniqueFacilitators.set(scenario.facilitator.name, scenario.facilitator); } @@ -551,24 +684,25 @@ async function runTest() { }); // Validate environment variables for all selected facilitators - log('\nπŸ” Validating facilitator environment variables...\n'); - const missingEnvVars: { facilitatorName: string; missingVars: string[] }[] = []; + log("\nπŸ” Validating facilitator environment variables...\n"); + const missingEnvVars: { facilitatorName: string; missingVars: string[] }[] = + []; // Environment variables managed by the test framework (don't require user to set) const systemManagedVars = new Set([ - 'PORT', - 'EVM_PRIVATE_KEY', - 'SVM_PRIVATE_KEY', - 'APTOS_PRIVATE_KEY', - 'STELLAR_PRIVATE_KEY', - 'EVM_NETWORK', - 'SVM_NETWORK', - 'APTOS_NETWORK', - 'STELLAR_NETWORK', - 'EVM_RPC_URL', - 'SVM_RPC_URL', - 'APTOS_RPC_URL', - 'STELLAR_RPC_URL', + "PORT", + "EVM_PRIVATE_KEY", + "SVM_PRIVATE_KEY", + "APTOS_PRIVATE_KEY", + "STELLAR_PRIVATE_KEY", + "EVM_NETWORK", + "SVM_NETWORK", + "APTOS_NETWORK", + "STELLAR_NETWORK", + "EVM_RPC_URL", + "SVM_RPC_URL", + "APTOS_RPC_URL", + "STELLAR_RPC_URL", ]); for (const [facilitatorName, facilitator] of uniqueFacilitators) { @@ -592,22 +726,26 @@ async function runTest() { } if (missingEnvVars.length > 0) { - errorLog('❌ Missing required environment variables for selected facilitators:\n'); + errorLog( + "❌ Missing required environment variables for selected facilitators:\n", + ); for (const { facilitatorName, missingVars } of missingEnvVars) { errorLog(` ${facilitatorName}:`); - missingVars.forEach(varName => errorLog(` - ${varName}`)); + missingVars.forEach((varName) => errorLog(` - ${varName}`)); } - errorLog('\nπŸ’‘ Please set the required environment variables and try again.\n'); + errorLog( + "\nπŸ’‘ Please set the required environment variables and try again.\n", + ); process.exit(1); } - log(' βœ… All required environment variables are present\n'); + log(" βœ… All required environment variables are present\n"); // Clean up any processes on test ports from previous runs try { - execSync('pnpm clean:ports', { cwd: process.cwd(), stdio: 'pipe' }); - verboseLog(' 🧹 Cleared test ports from previous runs'); - await new Promise(resolve => setTimeout(resolve, 500)); // Allow OS to release ports + execSync("pnpm clean:ports", { cwd: process.cwd(), stdio: "pipe" }); + verboseLog(" 🧹 Cleared test ports from previous runs"); + await new Promise((resolve) => setTimeout(resolve, 500)); // Allow OS to release ports } catch { // clean:ports may exit non-zero if no processes were found; that's fine } @@ -643,7 +781,7 @@ async function runTest() { const serverFacilitatorCombos: ServerFacilitatorCombo[] = []; const groupKey = (serverName: string, facilitatorName: string | undefined) => - `${serverName}::${facilitatorName || 'none'}`; + `${serverName}::${facilitatorName || "none"}`; const comboMap = new Map(); @@ -674,16 +812,12 @@ async function runTest() { const port = currentPort++; log(`\nπŸ›οΈ Starting facilitator: ${facilitatorName} on port ${port}`); - const manager = new FacilitatorManager( - facilitator.proxy, - port, - networks - ); + const manager = new FacilitatorManager(facilitator.proxy, port, networks); facilitatorManagers.set(facilitatorName, manager); } // Wait for all facilitators to be ready - log('\n⏳ Waiting for all facilitators to be ready...'); + log("\n⏳ Waiting for all facilitators to be ready..."); const facilitatorUrls = new Map(); for (const [facilitatorName, manager] of facilitatorManagers) { @@ -701,9 +835,10 @@ async function runTest() { const mockFacilitatorPort = currentPort++; log(`\n🎭 Starting mock facilitator on port ${mockFacilitatorPort}...`); const mockFacilitatorProcess: ChildProcess = spawn( - 'npx', ['tsx', 'index.ts'], + "npx", + ["tsx", "index.ts"], { - cwd: join(process.cwd(), 'mock-facilitator'), + cwd: join(process.cwd(), "mock-facilitator"), env: { ...process.env, PORT: mockFacilitatorPort.toString(), @@ -712,13 +847,13 @@ async function runTest() { APTOS_NETWORK: networks.aptos.caip2, STELLAR_NETWORK: networks.stellar.caip2, }, - stdio: 'pipe', + stdio: "pipe", }, ); - mockFacilitatorProcess.stderr?.on('data', (data: Buffer) => { + mockFacilitatorProcess.stderr?.on("data", (data: Buffer) => { verboseLog(`[mock-facilitator] stderr: ${data.toString().trim()}`); }); - mockFacilitatorProcess.stdout?.on('data', (data: Buffer) => { + mockFacilitatorProcess.stdout?.on("data", (data: Buffer) => { verboseLog(`[mock-facilitator] stdout: ${data.toString().trim()}`); }); @@ -732,25 +867,29 @@ async function runTest() { return { success: false }; } }, - { label: 'Mock facilitator' }, + { label: "Mock facilitator" }, ); if (!mockHealthy) { - log('❌ Failed to start mock facilitator'); + log("❌ Failed to start mock facilitator"); mockFacilitatorProcess.kill(); process.exit(1); } log(` βœ… Mock facilitator ready at ${mockFacilitatorUrl}`); - log('\nβœ… All facilitators are ready! Servers will be started/restarted as needed per test scenario.\n'); + log( + "\nβœ… All facilitators are ready! Servers will be started/restarted as needed per test scenario.\n", + ); log(`πŸ”§ Server/Facilitator combinations: ${serverFacilitatorCombos.length}`); - serverFacilitatorCombos.forEach(combo => { - log(` β€’ ${combo.serverName} + ${combo.facilitatorName || 'none'}: ${combo.scenarios.length} test(s)`); + serverFacilitatorCombos.forEach((combo) => { + log( + ` β€’ ${combo.serverName} + ${combo.facilitatorName || "none"}: ${combo.scenarios.length} test(s)`, + ); }); if (parsedArgs.parallel) { log(`\n⚑ Parallel mode enabled (concurrency: ${parsedArgs.concurrency})`); } - log(''); + log(""); // Track which facilitators processed which servers (for discovery validation) const facilitatorServerMap = new Map>(); // facilitatorName -> Set @@ -760,16 +899,22 @@ async function runTest() { scenario: TestScenario, port: number, localTestNumber: number, - cLog: { log: typeof log; verboseLog: typeof verboseLog; errorLog: typeof errorLog }, + cLog: { + log: typeof log; + verboseLog: typeof verboseLog; + errorLog: typeof errorLog; + }, ): Promise { - const facilitatorLabel = scenario.facilitator ? ` via ${scenario.facilitator.name}` : ''; + const facilitatorLabel = scenario.facilitator + ? ` via ${scenario.facilitator.name}` + : ""; const testName = `${scenario.client.name} β†’ ${scenario.server.name} β†’ ${scenario.endpoint.path}${facilitatorLabel}`; const clientConfig: ClientConfig = { evmPrivateKey: clientEvmPrivateKey!, svmPrivateKey: clientSvmPrivateKey!, - aptosPrivateKey: clientAptosPrivateKey || '', - stellarPrivateKey: clientStellarPrivateKey || '', + aptosPrivateKey: clientAptosPrivateKey || "", + stellarPrivateKey: clientStellarPrivateKey || "", serverUrl: `http://localhost:${port}`, endpointPath: scenario.endpoint.path, evmNetwork: networks.evm.caip2, @@ -785,7 +930,7 @@ async function runTest() { client: scenario.client.name, server: scenario.server.name, endpoint: scenario.endpoint.path, - facilitator: scenario.facilitator?.name || 'none', + facilitator: scenario.facilitator?.name || "none", protocolFamily: scenario.protocolFamily, passed: result.success, error: result.error, @@ -793,15 +938,41 @@ async function runTest() { network: result.payment_response?.network, }; - if (result.success) { + if ( + result.success && + shouldValidateOperationBinding( + selectedExtensions, + scenario.endpoint, + scenario.client.config, + ) + ) { + const operationBindingValidation = validateOperationBindingResult( + result, + scenario.endpoint, + ); + + if (!operationBindingValidation.success) { + detailedResult.passed = false; + detailedResult.error = operationBindingValidation.error; + cLog.log( + ` ❌ Operation-binding validation failed: ${operationBindingValidation.error}`, + ); + } else { + cLog.log(" βœ… Operation-binding extension validated"); + } + } + + if (detailedResult.passed) { cLog.log(` βœ… Test passed`); } else { - cLog.log(` ❌ Test failed: ${result.error}`); + cLog.log(` ❌ Test failed: ${detailedResult.error || result.error}`); if (result.verboseLogs && result.verboseLogs.length > 0) { cLog.log(` πŸ” Verbose logs:`); - result.verboseLogs.forEach(logLine => cLog.log(logLine)); + result.verboseLogs.forEach((logLine) => cLog.log(logLine)); } - cLog.verboseLog(` πŸ” Error details: ${JSON.stringify(result, null, 2)}`); + cLog.verboseLog( + ` πŸ” Error details: ${JSON.stringify(result, null, 2)}`, + ); } return detailedResult; @@ -814,7 +985,7 @@ async function runTest() { client: scenario.client.name, server: scenario.server.name, endpoint: scenario.endpoint.path, - facilitator: scenario.facilitator?.name || 'none', + facilitator: scenario.facilitator?.name || "none", protocolFamily: scenario.protocolFamily, passed: false, error: errorMsg, @@ -830,7 +1001,11 @@ async function runTest() { ): Promise { const { serverName, facilitatorName, scenarios, port } = combo; const server = uniqueServers.get(serverName)!; - const cLog = createComboLogger(combo.comboIndex, serverName, facilitatorName); + const cLog = createComboLogger( + combo.comboIndex, + serverName, + facilitatorName, + ); // Track facilitatorβ†’server mapping if (facilitatorName) { @@ -847,18 +1022,26 @@ async function runTest() { ? facilitatorUrls.get(facilitatorName) : undefined; - cLog.log(`πŸš€ Starting server: ${serverName} (port ${port}) with facilitator: ${facilitatorName || 'none'}`); + cLog.log( + `πŸš€ Starting server: ${serverName} (port ${port}) with facilitator: ${facilitatorName || "none"}`, + ); - const facilitatorConfig = facilitatorName ? uniqueFacilitators.get(facilitatorName)?.config : undefined; - const facilitatorSupportsAptos = facilitatorConfig?.protocolFamilies?.includes('aptos') ?? false; - const facilitatorSupportsStellar = facilitatorConfig?.protocolFamilies?.includes('stellar') ?? false; + const facilitatorConfig = facilitatorName + ? uniqueFacilitators.get(facilitatorName)?.config + : undefined; + const facilitatorSupportsAptos = + facilitatorConfig?.protocolFamilies?.includes("aptos") ?? false; + const facilitatorSupportsStellar = + facilitatorConfig?.protocolFamilies?.includes("stellar") ?? false; const serverConfig: ServerConfig = { port, evmPayTo: serverEvmAddress!, svmPayTo: serverSvmAddress!, - aptosPayTo: facilitatorSupportsAptos ? (serverAptosAddress || '') : '', - stellarPayTo: facilitatorSupportsStellar ? (serverStellarAddress || '') : '', + aptosPayTo: facilitatorSupportsAptos ? serverAptosAddress || "" : "", + stellarPayTo: facilitatorSupportsStellar + ? serverStellarAddress || "" + : "", networks, facilitatorUrl, mockFacilitatorUrl, @@ -867,15 +1050,15 @@ async function runTest() { const started = await startServer(serverProxy, serverConfig); if (!started) { cLog.log(`❌ Failed to start server ${serverName}`); - return scenarios.map(scenario => ({ + return scenarios.map((scenario) => ({ testNumber: nextTestNumber(), client: scenario.client.name, server: scenario.server.name, endpoint: scenario.endpoint.path, - facilitator: scenario.facilitator?.name || 'none', + facilitator: scenario.facilitator?.name || "none", protocolFamily: scenario.protocolFamily, passed: false, - error: 'Server failed to start', + error: "Server failed to start", })); } cLog.log(` βœ… Server ${serverName} ready`); @@ -888,7 +1071,7 @@ async function runTest() { try { for (const scenario of scenarios) { const tn = nextTestNumber(); - const isEvm = scenario.protocolFamily === 'evm'; + const isEvm = scenario.protocolFamily === "evm"; if (scenario.endpoint.permit2Direct) { await approvePermit2Approval(USDC_BASE_SEPOLIA); @@ -896,20 +1079,21 @@ async function runTest() { const endpointKey = scenario.endpoint.path; if (!coldStartedEndpoints.has(endpointKey)) { coldStartedEndpoints.add(endpointKey); - const token = - scenario.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') - ? MOCK_ERC20_BASE_SEPOLIA - : USDC_BASE_SEPOLIA; + const token = scenario.endpoint.extensions?.includes( + "erc20ApprovalGasSponsoring", + ) + ? MOCK_ERC20_BASE_SEPOLIA + : USDC_BASE_SEPOLIA; await fundClientForRevoke(); // Give fund tx 1s to propagate before submitting revoke (from client wallet) - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); await revokePermit2Approval(token); // Give revoke tx 1s to propagate before drain reads pending balance - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); await drainClientETH(); // Wait for RPC nonce propagation across load-balanced nodes before the // test client (which may use a separate RPC connection) queries the nonce. - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1500)); } } @@ -917,7 +1101,7 @@ async function runTest() { const releaseLock = await evmLock.acquire(facilitatorName); try { results.push(await runSingleTest(scenario, port, tn, cLog)); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); } finally { releaseLock(); } @@ -953,10 +1137,12 @@ async function runTest() { testResults = (await Promise.all(comboPromises)).flat(); // Run discovery validation before cleanup (while facilitators are still running) - const facilitatorsWithConfig = Array.from(uniqueFacilitators.values()).map((f: any) => ({ - proxy: facilitatorManagers.get(f.name)!.getProxy(), - config: f.config, - })); + const facilitatorsWithConfig = Array.from(uniqueFacilitators.values()).map( + (f: any) => ({ + proxy: facilitatorManagers.get(f.name)!.getProxy(), + config: f.config, + }), + ); const serversArray = Array.from(uniqueServers.values()); @@ -969,19 +1155,25 @@ async function runTest() { } // Run discovery validation if bazaar extension is enabled - const showBazaarOutput = shouldShowExtensionOutput('bazaar', selectedExtensions); - if (showBazaarOutput && shouldRunDiscoveryValidation(facilitatorsWithConfig, serversArray)) { - log('\nπŸ” Running Bazaar Discovery Validation...\n'); + const showBazaarOutput = shouldShowExtensionOutput( + "bazaar", + selectedExtensions, + ); + if ( + showBazaarOutput && + shouldRunDiscoveryValidation(facilitatorsWithConfig, serversArray) + ) { + log("\nπŸ” Running Bazaar Discovery Validation...\n"); await handleDiscoveryValidation( facilitatorsWithConfig, serversArray, discoveryServerPorts, - facilitatorServerMap + facilitatorServerMap, ); } // Clean up facilitators (servers already stopped in test loop for both modes) - log('\n🧹 Cleaning up...'); + log("\n🧹 Cleaning up..."); // Stop all facilitators const facilitatorStopPromises: Promise[] = []; @@ -989,38 +1181,40 @@ async function runTest() { log(` πŸ›‘ Stopping facilitator: ${facilitatorName}`); facilitatorStopPromises.push(manager.stop()); } - log(' πŸ›‘ Stopping mock facilitator'); + log(" πŸ›‘ Stopping mock facilitator"); mockFacilitatorProcess.kill(); await Promise.all(facilitatorStopPromises); // Calculate totals - const passed = testResults.filter(r => r.passed).length; - const failed = testResults.filter(r => !r.passed).length; + const passed = testResults.filter((r) => r.passed).length; + const failed = testResults.filter((r) => !r.passed).length; // Summary - log(''); - log('πŸ“Š Test Summary'); - log('=============='); + log(""); + log("πŸ“Š Test Summary"); + log("=============="); log(`🌐 Network: ${networkMode} (${getNetworkModeDescription(networkMode)})`); log(`βœ… Passed: ${passed}`); log(`❌ Failed: ${failed}`); log(`πŸ“ˆ Total: ${passed + failed}`); - log(''); + log(""); // Detailed results table - log('πŸ“‹ Detailed Test Results'); - log('========================'); - log(''); + log("πŸ“‹ Detailed Test Results"); + log("========================"); + log(""); // Group by status - const passedTests = testResults.filter(r => r.passed); - const failedTests = testResults.filter(r => !r.passed); + const passedTests = testResults.filter((r) => r.passed); + const failedTests = testResults.filter((r) => !r.passed); if (passedTests.length > 0) { - log('βœ… PASSED TESTS:'); - log(''); - passedTests.forEach(test => { - log(` #${test.testNumber.toString().padStart(2, ' ')}: ${test.client} β†’ ${test.server} β†’ ${test.endpoint}`); + log("βœ… PASSED TESTS:"); + log(""); + passedTests.forEach((test) => { + log( + ` #${test.testNumber.toString().padStart(2, " ")}: ${test.client} β†’ ${test.server} β†’ ${test.endpoint}`, + ); log(` Facilitator: ${test.facilitator}`); if (test.network) { log(` Network: ${test.network}`); @@ -1029,102 +1223,130 @@ async function runTest() { log(` Tx: ${test.transaction}`); } }); - log(''); + log(""); } if (failedTests.length > 0) { - log('❌ FAILED TESTS:'); - log(''); - failedTests.forEach(test => { - log(` #${test.testNumber.toString().padStart(2, ' ')}: ${test.client} β†’ ${test.server} β†’ ${test.endpoint}`); + log("❌ FAILED TESTS:"); + log(""); + failedTests.forEach((test) => { + log( + ` #${test.testNumber.toString().padStart(2, " ")}: ${test.client} β†’ ${test.server} β†’ ${test.endpoint}`, + ); log(` Facilitator: ${test.facilitator}`); if (test.network) { log(` Network: ${test.network}`); } - log(` Error: ${test.error || 'Unknown error'}`); + log(` Error: ${test.error || "Unknown error"}`); }); - log(''); + log(""); } // Breakdown by facilitator - const facilitatorBreakdown = testResults.reduce((acc, test) => { - const key = test.facilitator; - if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; - if (test.passed) acc[key].passed++; - else acc[key].failed++; - return acc; - }, {} as Record); - - log('πŸ“Š Breakdown by Facilitator:'); + const facilitatorBreakdown = testResults.reduce( + (acc, test) => { + const key = test.facilitator; + if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; + if (test.passed) acc[key].passed++; + else acc[key].failed++; + return acc; + }, + {} as Record, + ); + + log("πŸ“Š Breakdown by Facilitator:"); Object.entries(facilitatorBreakdown).forEach(([facilitator, stats]) => { const total = stats.passed + stats.failed; const passRate = total > 0 ? Math.round((stats.passed / total) * 100) : 0; - log(` ${facilitator.padEnd(15)} βœ… ${stats.passed} / ❌ ${stats.failed} (${passRate}%)`); + log( + ` ${facilitator.padEnd(15)} βœ… ${stats.passed} / ❌ ${stats.failed} (${passRate}%)`, + ); }); - log(''); + log(""); // Breakdown by server - const serverBreakdown = testResults.reduce((acc, test) => { - const key = test.server; - if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; - if (test.passed) acc[key].passed++; - else acc[key].failed++; - return acc; - }, {} as Record); - - log('πŸ“Š Breakdown by Server:'); + const serverBreakdown = testResults.reduce( + (acc, test) => { + const key = test.server; + if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; + if (test.passed) acc[key].passed++; + else acc[key].failed++; + return acc; + }, + {} as Record, + ); + + log("πŸ“Š Breakdown by Server:"); Object.entries(serverBreakdown).forEach(([server, stats]) => { const total = stats.passed + stats.failed; const passRate = total > 0 ? Math.round((stats.passed / total) * 100) : 0; - log(` ${server.padEnd(20)} βœ… ${stats.passed} / ❌ ${stats.failed} (${passRate}%)`); + log( + ` ${server.padEnd(20)} βœ… ${stats.passed} / ❌ ${stats.failed} (${passRate}%)`, + ); }); - log(''); + log(""); // Breakdown by client - const clientBreakdown = testResults.reduce((acc, test) => { - const key = test.client; - if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; - if (test.passed) acc[key].passed++; - else acc[key].failed++; - return acc; - }, {} as Record); - - log('πŸ“Š Breakdown by Client:'); + const clientBreakdown = testResults.reduce( + (acc, test) => { + const key = test.client; + if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; + if (test.passed) acc[key].passed++; + else acc[key].failed++; + return acc; + }, + {} as Record, + ); + + log("πŸ“Š Breakdown by Client:"); Object.entries(clientBreakdown).forEach(([client, stats]) => { const total = stats.passed + stats.failed; const passRate = total > 0 ? Math.round((stats.passed / total) * 100) : 0; - log(` ${client.padEnd(20)} βœ… ${stats.passed} / ❌ ${stats.failed} (${passRate}%)`); + log( + ` ${client.padEnd(20)} βœ… ${stats.passed} / ❌ ${stats.failed} (${passRate}%)`, + ); }); - log(''); + log(""); // Protocol family breakdown - const protocolBreakdown = testResults.reduce((acc, test) => { - const key = test.protocolFamily; - if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; - if (test.passed) acc[key].passed++; - else acc[key].failed++; - return acc; - }, {} as Record); + const protocolBreakdown = testResults.reduce( + (acc, test) => { + const key = test.protocolFamily; + if (!acc[key]) acc[key] = { passed: 0, failed: 0 }; + if (test.passed) acc[key].passed++; + else acc[key].failed++; + return acc; + }, + {} as Record, + ); if (Object.keys(protocolBreakdown).length > 1) { - log('πŸ“Š Protocol Family Breakdown:'); + log("πŸ“Š Protocol Family Breakdown:"); Object.entries(protocolBreakdown).forEach(([protocol, stats]) => { const total = stats.passed + stats.failed; - log(` ${protocol.toUpperCase()}: βœ… ${stats.passed} / ❌ ${stats.failed} / πŸ“ˆ ${total} total`); + log( + ` ${protocol.toUpperCase()}: βœ… ${stats.passed} / ❌ ${stats.failed} / πŸ“ˆ ${total} total`, + ); }); - log(''); + log(""); } // Write structured JSON output if requested if (parsedArgs.outputJson) { - const breakdown = (results: DetailedTestResult[], key: keyof DetailedTestResult) => - results.reduce((acc, test) => { - const k = String(test[key]); - if (!acc[k]) acc[k] = { passed: 0, failed: 0 }; - if (test.passed) acc[k].passed++; - else acc[k].failed++; - return acc; - }, {} as Record); + const breakdown = ( + results: DetailedTestResult[], + key: keyof DetailedTestResult, + ) => + results.reduce( + (acc, test) => { + const k = String(test[key]); + if (!acc[k]) acc[k] = { passed: 0, failed: 0 }; + if (test.passed) acc[k].passed++; + else acc[k].failed++; + return acc; + }, + {} as Record, + ); const jsonOutput = { summary: { @@ -1135,10 +1357,10 @@ async function runTest() { }, results: testResults, breakdowns: { - byFacilitator: breakdown(testResults, 'facilitator'), - byServer: breakdown(testResults, 'server'), - byClient: breakdown(testResults, 'client'), - byProtocolFamily: breakdown(testResults, 'protocolFamily'), + byFacilitator: breakdown(testResults, "facilitator"), + byServer: breakdown(testResults, "server"), + byClient: breakdown(testResults, "client"), + byProtocolFamily: breakdown(testResults, "protocolFamily"), }, }; @@ -1155,4 +1377,4 @@ async function runTest() { } // Run the test -runTest().catch(error => errorLog(error)); +runTest().catch((error) => errorLog(error)); diff --git a/typescript/packages/http/express/src/operation-binding.e2e.test.ts b/typescript/packages/http/express/src/operation-binding.e2e.test.ts new file mode 100644 index 0000000000..ed43dea4fd --- /dev/null +++ b/typescript/packages/http/express/src/operation-binding.e2e.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it } from "vitest"; +import express from "express"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import { x402Client, x402HTTPClient } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { x402ResourceServer } from "@x402/core/server"; +import type { Network, PaymentRequired } from "@x402/core/types"; +import { + CashFacilitatorClient, + CashSchemeNetworkClient, + CashSchemeNetworkFacilitator, + CashSchemeNetworkServer, +} from "../../../core/test/mocks/cash"; +import { + OPERATION_BINDING, + declareOperationBindingExtension, + operationBindingResourceServerExtension, +} from "@x402/extensions"; +import { wrapFetchWithPayment } from "../../fetch/src"; +import { paymentMiddleware } from "./index"; + +describe("operation-binding express HTTP flow", () => { + let httpServer: Server | undefined; + + afterEach(async () => { + if (!httpServer) { + return; + } + + await new Promise((resolve, reject) => { + httpServer?.close(error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + httpServer = undefined; + }); + + it("surfaces operation-binding metadata during a paid fetch flow", async () => { + const facilitator = new x402Facilitator().register( + "x402:cash", + new CashSchemeNetworkFacilitator(), + ); + const facilitatorClient = new CashFacilitatorClient(facilitator); + + const server = new x402ResourceServer(facilitatorClient); + server.register("x402:cash", new CashSchemeNetworkServer()); + server.registerExtension(operationBindingResourceServerExtension); + await server.initialize(); + + const app = express(); + app.use( + paymentMiddleware( + { + "GET /api/operation-binding": { + accepts: { + scheme: "cash", + payTo: "merchant@example.com", + price: "$0.10", + network: "x402:cash" as Network, + }, + description: "Protected operation-binding endpoint", + mimeType: "application/json", + extensions: { + [OPERATION_BINDING]: declareOperationBindingExtension({ + operationId: "cash.operationBinding", + policyVersion: "2026-04-04", + bindBody: false, + }), + }, + }, + }, + server, + ), + ); + + app.get("/api/operation-binding", (req, res) => { + res.json({ + ok: true, + query: req.query, + }); + }); + + httpServer = await new Promise(resolve => { + const listeningServer = app.listen(0, () => resolve(listeningServer)); + }); + + const port = (httpServer.address() as AddressInfo).port; + const baseUrl = `http://127.0.0.1:${port}`; + + const paymentClient = new x402Client().register( + "x402:cash", + new CashSchemeNetworkClient("John"), + ); + + let capturedPaymentRequired: PaymentRequired | undefined; + const httpClient = new x402HTTPClient(paymentClient).onPaymentRequired( + async ({ paymentRequired }) => { + capturedPaymentRequired = paymentRequired; + }, + ); + + const response = await wrapFetchWithPayment(fetch, httpClient)( + `${baseUrl}/api/operation-binding?units=metric&lang=en`, + { + method: "GET", + }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("PAYMENT-RESPONSE")).toBeTruthy(); + + const settlement = httpClient.getPaymentSettleResponse(name => response.headers.get(name)); + expect(settlement.success).toBe(true); + expect(settlement.network).toBe("x402:cash"); + + const body = await response.json(); + expect(body).toEqual({ + ok: true, + query: { + lang: "en", + units: "metric", + }, + }); + + expect(capturedPaymentRequired).toBeDefined(); + const extension = capturedPaymentRequired?.extensions?.[OPERATION_BINDING] as + | { info?: Record } + | undefined; + + expect(extension?.info).toMatchObject({ + transport: "http", + method: "GET", + pathTemplate: "/api/operation-binding", + operationId: "cash.operationBinding", + policyVersion: "2026-04-04", + canonicalization: "rfc8785-jcs", + digestAlgorithm: "sha-256", + bindPathParams: true, + bindQuery: true, + bindBody: false, + }); + + const resourceUrl = new URL(extension?.info?.resourceUrl as string); + expect(resourceUrl.pathname).toBe("/api/operation-binding"); + expect(resourceUrl.searchParams.get("units")).toBe("metric"); + expect(resourceUrl.searchParams.get("lang")).toBe("en"); + }); +});