diff --git a/specs/extensions/extension-facilitator-attestation.md b/specs/extensions/extension-facilitator-attestation.md new file mode 100644 index 0000000000..9d49b6460c --- /dev/null +++ b/specs/extensions/extension-facilitator-attestation.md @@ -0,0 +1,276 @@ +# Facilitator Attestation Extension + +**Status:** Draft +**Version:** 1.0 +**Extension Key:** `facilitator-attestation` +**Placement:** `SettlementResponse.extensions["facilitator-attestation"]` + +--- + +**1. Overview** + +The Facilitator Attestation Extension adds a **facilitator-signed settlement proof** to x402 payment responses. After a successful settlement, the facilitating party signs a `SettlementAttestation` object and attaches it to the `SettlementResponse.extensions` field. + +This extension is **complementary to the offer-receipt extension** (extension key `offer-receipt`). The two serve different trust roles: + +| Property | offer-receipt (receipt) | facilitator-attestation | +|---|---|---| +| **Signer** | Resource server | Facilitator | +| **Signed at** | Service delivery | Settlement confirmation | +| **Includes txHash** | Optional (privacy default: false) | Always (audit requirement) | +| **Includes amount** | No | Yes | +| **Includes facilitator fee** | No | Yes | +| **Primary use case** | Proof of delivery | Audit / compliance / fee transparency | +| **Chain binding** | EIP-712 domain chainId=1 (static) | EIP-712 domain chainId = payment chain | + +When both extensions are active, clients can compose a **BusinessReceipt** (§7) that provides both settlement proof and delivery proof. + +**2. Motivation** + +The offer-receipt extension deliberately omits amount and asset to preserve privacy and is signed by the resource server to prove delivery. This leaves a gap for actors who need: + +1. **Cryptographic proof of what was paid** — amount, token, payer, payee, in a form that cannot be forged or altered by the recipient. +2. **Facilitator fee transparency** — verifiable record of what fee the facilitating party took. +3. **Audit and compliance** — structured evidence for accounting systems, tax reporting, or regulatory filings. +4. **Interoperability** — a machine-readable format compatible with emerging settlement proof standards (e.g., ERC-8183 Business Receipts). + +The facilitator is the natural signer for settlement proofs because: +- The facilitator observes the on-chain transaction and can attest to its finality. +- The facilitator's signature is independent of the resource server, providing an additional trust anchor. +- Facilitators already hold signing keys for x402 operations. + +**3. Signed Artifact Structure** + +**3.1 Object Shape** + +The `SettlementAttestation` MUST have the following structure: + +```json +{ + "format": "eip712", + "payload": { ... }, + "signature": "0x..." +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `format` | string | Yes | Always `"eip712"` for this extension | +| `payload` | object | Yes | The canonical `SettlementAttestationPayload` fields (§4) | +| `signature` | string | Yes | Hex-encoded ECDSA signature (`0x`-prefixed, 65 bytes: r+s+v) | + +This extension uses EIP-712 exclusively. JWS is not supported: EVM facilitator keys are secp256k1 keys, and EIP-712 is the natural signing format for on-chain-aware parties. + +**3.2 EIP-712 Domain** + +```javascript +{ + name: "x402-receipt", + version: "1", + chainId: +} +``` + +**Unlike the offer-receipt extension**, the `chainId` in the EIP-712 domain is the **actual EIP-155 chain ID of the payment network** (e.g., `8453` for Base). This provides per-chain replay protection: an attestation for a Base payment cannot be presented as an Ethereum mainnet payment by tampering the `chainId` field. + +**4. SettlementAttestation Payload (§4)** + +**4.1 Fields** + +| Field | EIP-712 Type | JSON Type | Required | Description | +|---|---|---|---|---| +| `version` | `uint256` | integer | Yes | Schema version (currently `1`) | +| `paymentId` | `bytes32` | `string` | Yes | Payment identifier (see §4.3) | +| `chainId` | `uint256` | `string` | Yes | EIP-155 chain ID of the payment network | +| `payer` | `address` | `string` | Yes | Payer wallet address (EIP-55 checksum) | +| `payee` | `address` | `string` | Yes | Payee wallet address — `PaymentRequirements.payTo` (EIP-55) | +| `token` | `string` | `string` | Yes | Token contract address (EIP-55), or `"native"` | +| `amount` | `uint256` | `string` | Yes | Settled amount in token's smallest unit (decimal string) | +| `facilitator` | `string` | `string` | Yes | Facilitator address or identifier | +| `facilitatorFee` | `uint256` | `string` | Yes | Fee taken by facilitator (decimal string; `"0"` if none) | +| `txHash` | `bytes32` | `string` | Yes | On-chain transaction hash (`0x`-prefixed 32-byte hex) | +| `settledAt` | `uint256` | `string` | Yes | Unix timestamp (seconds) of settlement confirmation | + +All fields are REQUIRED. There are no optional fields — attestations must be complete to be useful for audit purposes. + +**4.2 EIP-712 Typed Data** + +```typescript +const ATTESTATION_TYPES = { + SettlementAttestation: [ + { name: "version", type: "uint256" }, + { name: "paymentId", type: "bytes32" }, + { name: "chainId", type: "uint256" }, + { name: "payer", type: "address" }, + { name: "payee", type: "address" }, + { name: "token", type: "string" }, + { name: "amount", type: "uint256" }, + { name: "facilitator", type: "string" }, + { name: "facilitatorFee", type: "uint256" }, + { name: "txHash", type: "bytes32" }, + { name: "settledAt", type: "uint256" }, + ], +}; +``` + +**4.3 paymentId Derivation** + +The `paymentId` is a `bytes32` identifier that links this attestation to the specific payment. Implementations SHOULD derive `paymentId` from the transaction hash of the on-chain settlement (normalised to 32-byte lowercase hex). A `paymentId` of all zeros (`0x000...000`) is invalid and MUST be rejected. + +Future versions of this spec may define a canonical derivation method using a hash of the full payment payload for cross-chain and off-chain payment schemes. + +**4.4 Serialization Rules** + +Implementations MUST follow these serialization rules to ensure interoperability: + +- **`uint256` values** (amount, facilitatorFee, chainId, settledAt): encoded as **decimal string** with no leading zeros and no `0x` prefix. Example: `"1000000"`. +- **`bytes32` values** (paymentId, txHash): encoded as `0x`-prefixed **lowercase hex**, 64 hex characters. Example: `"0xabcd...1234"`. +- **`address` values** (payer, payee): encoded as **EIP-55 checksum** format. Example: `"0x70997970C51812dc3A010C7d01b50e0d17dc79C8"`. +- **`string` values** (token, facilitator): `token` is either `"native"` or an EIP-55 address; `facilitator` is either an EIP-55 address or a URL/DID identifying the facilitator service. + +**5. Wire Shape** + +The attestation is placed at: + +``` +SettlementResponse.extensions["facilitator-attestation"].info.attestation +``` + +Full example: + +```json +{ + "success": true, + "transaction": "0xabcdef...", + "network": "eip155:8453", + "payer": "0x70997970...", + "extensions": { + "facilitator-attestation": { + "info": { + "attestation": { + "format": "eip712", + "payload": { + "version": 1, + "paymentId": "0xabcdef...1234", + "chainId": "8453", + "payer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "payee": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "1000000", + "facilitator": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "facilitatorFee": "3000", + "txHash": "0xabcdef...1234", + "settledAt": "1700000000" + }, + "signature": "0x..." + } + }, + "schema": { ... } + } + } +} +``` + +**6. Verification (§6)** + +**6.1 Field Validation** + +Before cryptographic verification, implementations MUST check: + +1. `format` is `"eip712"`. +2. `version` is `1`. +3. `paymentId` matches `^0x[0-9a-fA-F]{64}$` and is not all zeros. +4. `chainId` is a parseable positive integer string. +5. `payer` and `payee` are valid EVM addresses. +6. `token` is `"native"` or a valid EVM address. +7. `amount` and `facilitatorFee` are parseable non-negative decimal integer strings. +8. `txHash` matches `^0x[0-9a-fA-F]{64}$`. +9. `settledAt` is a parseable positive integer (Unix seconds). +10. `signature` is `0x`-prefixed 65-byte hex. + +**6.2 Signature Verification** + +1. Construct the EIP-712 typed data using the `payload` fields and domain `{ name: "x402-receipt", version: "1", chainId: }`. +2. Recover the signer address via `ecrecover` over the EIP-712 hash. +3. If `facilitator` is a valid EVM address, verify the recovered signer equals the `facilitator` field (case-insensitive). +4. If `facilitator` is a non-address identifier (URL, DID), the verifier MUST resolve the facilitator's authorised signing key through an out-of-band mechanism and verify accordingly. + +**7. BusinessReceipt Composition (§7)** + +When both `offer-receipt` and `facilitator-attestation` extensions are active, clients MAY compose a **BusinessReceipt** to get a single structured record: + +```typescript +interface BusinessReceipt { + status: "COMPLETE" | "PAYMENT_ONLY" | "DELIVERY_ONLY" | "MISMATCH"; + attestation?: SignedSettlementAttestation; // facilitator-signed + deliveryReceipt?: SignedReceipt; // resource-server-signed (from offer-receipt) + paymentId?: string; + mismatchDetails?: string[]; +} +``` + +Composition rules: +- If only the attestation is present: `status = "PAYMENT_ONLY"`. +- If only the delivery receipt is present: `status = "DELIVERY_ONLY"`. +- If both are present and `paymentId` is consistent: `status = "COMPLETE"`. +- If both are present but `paymentId` values differ: `status = "MISMATCH"`. + +**8. Server Implementation** + +Facilitators register this extension with `createFacilitatorAttestationExtension()` from `@x402/extensions/facilitator-attestation`: + +```typescript +import { + createFacilitatorAttestationExtension, + declareFacilitatorAttestationExtension, +} from "@x402/extensions/facilitator-attestation"; + +// Create extension (once, at startup) +const attestationExtension = createFacilitatorAttestationExtension({ + signFn: mySignTypedDataFn, // viem account.signTypedData or ethers signer + facilitatorAddress: "0x...", // facilitator's signing key address + feeFraction: 0.003, // 0.3% facilitator fee +}); + +// Register with x402ResourceServer +server.registerExtension(attestationExtension); + +// Declare in route config +const routes = { + "GET /api/data": { + accepts: { ... }, + extensions: { + ...declareFacilitatorAttestationExtension(), + }, + }, +}; +``` + +**9. Security Considerations** + +**9.1 Key Management** + +The facilitator's signing key produces legal-weight attestations. Implementers SHOULD: +- Use HSM or KMS-backed keys, not hot wallets. +- Rotate keys periodically and publish key rotation events. +- Bind the signing key's address to the facilitator service via an on-chain or off-chain registry. + +**9.2 Replay Protection** + +The EIP-712 domain includes `chainId`, binding each attestation to a specific chain. An attestation for chain A cannot be replayed on chain B by modifying the `chainId` field — doing so would change the EIP-712 hash and invalidate the signature. + +**9.3 Data Minimisation** + +Unlike on-chain events, this attestation travels in HTTP response headers. Implementers should be aware that `payer`, `payee`, `amount`, and `txHash` are all revealed to any party that can observe the HTTP response. This is intentional for audit use cases but should be considered in privacy-sensitive deployments. + +**9.4 Fee Calculation** + +The `facilitatorFee` field is derived by the facilitator and is not independently verifiable from the on-chain transaction alone (fees may be taken off-chain or through a separate mechanism). Verifiers SHOULD treat `facilitatorFee` as a facilitator-attested claim, not an on-chain fact. + +**10. Compatibility** + +This extension: +- Does NOT modify any fields in `@x402/core`. +- Is compatible with x402 v1 and v2. +- Works alongside `offer-receipt` (the two are designed to compose into a BusinessReceipt). +- Does NOT require Solidity contracts (batch anchoring is a separate, future extension). diff --git a/typescript/packages/core/src/facilitator/x402Facilitator.ts b/typescript/packages/core/src/facilitator/x402Facilitator.ts index daa9836905..1723b4e583 100644 --- a/typescript/packages/core/src/facilitator/x402Facilitator.ts +++ b/typescript/packages/core/src/facilitator/x402Facilitator.ts @@ -1,6 +1,10 @@ import { x402Version } from ".."; import { SettleResponse, VerifyResponse } from "../types/facilitator"; -import { FacilitatorExtension } from "../types/extensions"; +import { + FacilitatorExtension, + FacilitatorSettleContext, + FacilitatorSettleResultContext, +} from "../types/extensions"; import { SchemeNetworkFacilitator, FacilitatorContext } from "../types/mechanisms"; import { PaymentPayload, PaymentRequirements } from "../types/payments"; import { Network } from "../types"; @@ -23,14 +27,7 @@ export interface FacilitatorVerifyFailureContext extends FacilitatorVerifyContex error: Error; } -export interface FacilitatorSettleContext { - paymentPayload: PaymentPayload; - requirements: PaymentRequirements; -} - -export interface FacilitatorSettleResultContext extends FacilitatorSettleContext { - result: SettleResponse; -} +export type { FacilitatorSettleContext, FacilitatorSettleResultContext }; export interface FacilitatorSettleFailureContext extends FacilitatorSettleContext { error: Error; @@ -467,6 +464,25 @@ export class x402Facilitator { await hook(resultContext); } + // Run enrichSettleResponse hooks on all registered extensions + for (const [key, extension] of this.extensions) { + if (!extension.enrichSettleResponse) continue; + try { + const extensionData = await extension.enrichSettleResponse(resultContext); + if (extensionData !== undefined) { + if (!settleResult.extensions) { + settleResult.extensions = {}; + } + settleResult.extensions[key] = extensionData; + } + } catch (error) { + console.error( + `[x402] facilitator extension "${key}" enrichSettleResponse threw`, + error instanceof Error ? error : new Error(String(error)), + ); + } + } + return settleResult; } catch (error) { const failureContext: FacilitatorSettleFailureContext = { diff --git a/typescript/packages/core/src/types/extensions.ts b/typescript/packages/core/src/types/extensions.ts index a85823da9f..1309f3a7e3 100644 --- a/typescript/packages/core/src/types/extensions.ts +++ b/typescript/packages/core/src/types/extensions.ts @@ -1,4 +1,5 @@ import type { VerifyResponse, SettleResponse } from "./facilitator"; +import type { PaymentPayload, PaymentRequirements } from "./payments"; import type { PaymentRequiredContext, SettleResultContext, @@ -26,8 +27,22 @@ export type { VerifiedPaymentCanceledContext, }; +export interface FacilitatorSettleContext { + paymentPayload: PaymentPayload; + requirements: PaymentRequirements; +} + +export interface FacilitatorSettleResultContext extends FacilitatorSettleContext { + result: SettleResponse; +} + export interface FacilitatorExtension { key: string; + /** + * Called after successful settlement. Return value is placed at SettleResponse.extensions[key]. + * Returning undefined skips the key entirely. Errors are caught and logged — they never fail the settlement. + */ + enrichSettleResponse?: (context: FacilitatorSettleResultContext) => Promise; } /** diff --git a/typescript/packages/core/src/types/index.ts b/typescript/packages/core/src/types/index.ts index 4af52b328d..6a6ff44db5 100644 --- a/typescript/packages/core/src/types/index.ts +++ b/typescript/packages/core/src/types/index.ts @@ -34,6 +34,8 @@ export type { export type { PaymentRequirementsV1, PaymentRequiredV1, PaymentPayloadV1 } from "./v1"; export type { FacilitatorExtension, + FacilitatorSettleContext, + FacilitatorSettleResultContext, ResourceServerExtension, ResourceServerExtensionHooks, PaymentRequiredContext, diff --git a/typescript/packages/core/test/unit/facilitator/x402Facilitator.extensions.test.ts b/typescript/packages/core/test/unit/facilitator/x402Facilitator.extensions.test.ts new file mode 100644 index 0000000000..230cf9253a --- /dev/null +++ b/typescript/packages/core/test/unit/facilitator/x402Facilitator.extensions.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, vi } from "vitest"; +import { x402Facilitator } from "../../../src/facilitator/x402Facilitator"; +import { + FacilitatorExtension, + FacilitatorSettleResultContext, +} from "../../../src/types/extensions"; +import { + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, +} from "../../../src/types"; +import { SchemeNetworkFacilitator } from "../../../src/types/mechanisms"; + +// --------------------------------------------------------------------------- +// Minimal mock scheme facilitator +// --------------------------------------------------------------------------- + +class MockSchemeFacilitator implements SchemeNetworkFacilitator { + readonly scheme = "exact"; + readonly caipFamily = "eip155:*"; + + constructor( + private settleFn?: ( + payload: PaymentPayload, + requirements: PaymentRequirements, + ) => Promise, + ) {} + + getExtra(_: string): Record | undefined { + return undefined; + } + + getSigners(_: string): string[] { + return []; + } + + async verify( + _payload: PaymentPayload, + _requirements: PaymentRequirements, + ): Promise { + return { isValid: true }; + } + + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + if (this.settleFn) { + return this.settleFn(payload, requirements); + } + return { + success: true, + transaction: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + network: requirements.network, + payer: "0xPayer", + }; + } +} + +// --------------------------------------------------------------------------- +// Test data builders +// --------------------------------------------------------------------------- + +const buildPaymentPayload = (): PaymentPayload => ({ + x402Version: 2, + payload: {}, + accepted: { + scheme: "exact", + network: "eip155:8453", + asset: "0xUSDC", + amount: "1000000", + payTo: "0xRecipient", + maxTimeoutSeconds: 300, + extra: {}, + }, + resource: { + url: "https://example.com/resource", + description: "Test resource", + mimeType: "application/json", + }, +}); + +const buildPaymentRequirements = (): PaymentRequirements => ({ + scheme: "exact", + network: "eip155:8453", + asset: "0xUSDC", + amount: "1000000", + payTo: "0xRecipient", + maxTimeoutSeconds: 300, + extra: {}, +}); + +const buildFacilitator = ( + settleFn?: (p: PaymentPayload, r: PaymentRequirements) => Promise, +) => { + const facilitator = new x402Facilitator(); + facilitator.register("eip155:8453", new MockSchemeFacilitator(settleFn)); + return facilitator; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("x402Facilitator - FacilitatorExtension.enrichSettleResponse hooks", () => { + // Test 1: Register extension — extension is stored by key + it("should store a registered extension by key", () => { + const facilitator = buildFacilitator(); + + const ext: FacilitatorExtension = { + key: "test-ext", + enrichSettleResponse: async () => ({ foo: "bar" }), + }; + + facilitator.registerExtension(ext); + + expect(facilitator.getExtension("test-ext")).toBe(ext); + expect(facilitator.getExtensions()).toContain("test-ext"); + }); + + // Test 2: enrichSettleResponse called on success, with correct context + it("should call enrichSettleResponse after successful settlement with correct context", async () => { + const facilitator = buildFacilitator(); + + let capturedContext: FacilitatorSettleResultContext | undefined; + + facilitator.registerExtension({ + key: "ctx-ext", + enrichSettleResponse: async ctx => { + capturedContext = ctx; + return undefined; + }, + }); + + const payload = buildPaymentPayload(); + const requirements = buildPaymentRequirements(); + + await facilitator.settle(payload, requirements); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.paymentPayload).toEqual(payload); + expect(capturedContext!.requirements).toEqual(requirements); + expect(capturedContext!.result.success).toBe(true); + }); + + // Test 3: enrichSettleResponse modifies extensions field + it("should add extension data to SettleResponse.extensions[key]", async () => { + const facilitator = buildFacilitator(); + + facilitator.registerExtension({ + key: "my-ext", + enrichSettleResponse: async () => ({ signed: true, value: 42 }), + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(result.extensions).toBeDefined(); + expect(result.extensions!["my-ext"]).toEqual({ signed: true, value: 42 }); + }); + + // Test 4: Multiple extensions — both hooks run in order + it("should call all registered enrichSettleResponse hooks in registration order", async () => { + const facilitator = buildFacilitator(); + const callOrder: string[] = []; + + facilitator.registerExtension({ + key: "ext-alpha", + enrichSettleResponse: async () => { + callOrder.push("alpha"); + return { from: "alpha" }; + }, + }); + + facilitator.registerExtension({ + key: "ext-beta", + enrichSettleResponse: async () => { + callOrder.push("beta"); + return { from: "beta" }; + }, + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(callOrder).toEqual(["alpha", "beta"]); + expect(result.extensions!["ext-alpha"]).toEqual({ from: "alpha" }); + expect(result.extensions!["ext-beta"]).toEqual({ from: "beta" }); + }); + + // Test 5: Extension error doesn't break settlement + it("should not fail settlement when an extension hook throws", async () => { + const facilitator = buildFacilitator(); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + facilitator.registerExtension({ + key: "bad-ext", + enrichSettleResponse: async () => { + throw new Error("Extension signing failed"); + }, + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(result.success).toBe(true); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("bad-ext"), expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + // Test 6: Extension without enrichSettleResponse — SettleResponse unchanged + it("should leave extensions undefined when registered extension has no enrichSettleResponse", async () => { + const facilitator = buildFacilitator(); + + // Extension that only has key — no enrichSettleResponse hook + facilitator.registerExtension({ key: "no-hook-ext" }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(result.extensions).toBeUndefined(); + }); + + // Test 7: Failed settlement — hooks not called + it("should not call enrichSettleResponse when settlement throws", async () => { + const facilitator = buildFacilitator(async () => { + throw new Error("On-chain settlement failed"); + }); + + let hookCalled = false; + + facilitator.registerExtension({ + key: "should-not-run", + enrichSettleResponse: async () => { + hookCalled = true; + return { data: "oops" }; + }, + }); + + await expect( + facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()), + ).rejects.toThrow("On-chain settlement failed"); + + expect(hookCalled).toBe(false); + }); + + // Test 8: Extensions field preserved through JSON serialization + it("should preserve extensions field through JSON round-trip", async () => { + const facilitator = buildFacilitator(); + + facilitator.registerExtension({ + key: "serial-ext", + enrichSettleResponse: async () => ({ + attestation: { format: "eip712", signature: "0xdeadbeef" }, + }), + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + const serialized = JSON.stringify(result); + const parsed: SettleResponse = JSON.parse(serialized); + + expect(parsed.extensions).toBeDefined(); + expect(parsed.extensions!["serial-ext"]).toEqual({ + attestation: { format: "eip712", signature: "0xdeadbeef" }, + }); + }); + + // Test 9: Context contains correct txHash and network from the settlement result + it("should pass the correct transaction hash and network from settlement result in context", async () => { + const expectedTxHash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + const expectedNetwork = "eip155:8453"; + + const facilitator = buildFacilitator(async (_payload, req) => ({ + success: true, + transaction: expectedTxHash, + network: req.network, + payer: "0xPayer", + })); + + let capturedTxHash: string | undefined; + let capturedNetwork: string | undefined; + + facilitator.registerExtension({ + key: "ctx-check", + enrichSettleResponse: async ctx => { + capturedTxHash = ctx.result.transaction; + capturedNetwork = ctx.result.network; + return undefined; + }, + }); + + await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(capturedTxHash).toBe(expectedTxHash); + expect(capturedNetwork).toBe(expectedNetwork); + }); + + // Test 10 (integration-style): end-to-end — extension adds data to settle response + it("end-to-end: registered extension adds extensions field to /settle response shape", async () => { + const facilitator = buildFacilitator(); + + facilitator.registerExtension({ + key: "test-ext", + enrichSettleResponse: async ctx => ({ + foo: "bar", + txHash: ctx.result.transaction, + }), + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(result.success).toBe(true); + expect(result.extensions).toBeDefined(); + expect(result.extensions!["test-ext"]).toMatchObject({ foo: "bar" }); + expect(typeof (result.extensions!["test-ext"] as { txHash: string }).txHash).toBe("string"); + }); + + // Additional: enrichSettleResponse returning undefined does not create empty extensions key + it("should not set extensions[key] when enrichSettleResponse returns undefined", async () => { + const facilitator = buildFacilitator(); + + facilitator.registerExtension({ + key: "skip-ext", + enrichSettleResponse: async () => undefined, + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + expect(result.extensions?.["skip-ext"]).toBeUndefined(); + }); + + // Additional: enrichSettleResponse is not called after a failed (success: false) scheme response + // (the scheme returns success:false — note: currently x402Facilitator doesn't distinguish + // success:false from the scheme vs thrown errors, so hooks run regardless for non-throw results) + it("should call enrichSettleResponse even when scheme returns success:false (scheme-level failure)", async () => { + const facilitator = buildFacilitator(async (_p, req) => ({ + success: false, + transaction: "", + network: req.network, + errorReason: "insufficient_funds", + })); + + let hookCalled = false; + + facilitator.registerExtension({ + key: "failure-ext", + enrichSettleResponse: async ctx => { + hookCalled = true; + // Extension can inspect ctx.result.success and choose to skip + if (!ctx.result.success) return undefined; + return { data: "never" }; + }, + }); + + const result = await facilitator.settle(buildPaymentPayload(), buildPaymentRequirements()); + + // Settlement completed (did not throw), extension was called + expect(hookCalled).toBe(true); + expect(result.success).toBe(false); + // Extension returned undefined (skipped) for failed settlement + expect(result.extensions?.["failure-ext"]).toBeUndefined(); + }); +});