Skip to content

feat(core): add FacilitatorExtension.enrichSettleResponse hook#2339

Open
javierpmateos wants to merge 1 commit into
x402-foundation:mainfrom
javierpmateos:feat/facilitator-extension-hooks
Open

feat(core): add FacilitatorExtension.enrichSettleResponse hook#2339
javierpmateos wants to merge 1 commit into
x402-foundation:mainfrom
javierpmateos:feat/facilitator-extension-hooks

Conversation

@javierpmateos
Copy link
Copy Markdown
Contributor

feat(core): add FacilitatorExtension hooks for facilitator-side extension data

Summary

Refs #1802. Adds enrichSettleResponse to the FacilitatorExtension interface and wires it
into x402Facilitator.settle() so that facilitator-registered extensions can inject signed or
derived data into SettleResponse.extensions before the response is returned to the resource
server.

Background

The Gap

Today's x402 extension flow covers the resource server side:

  • ResourceServerExtension.enrichPaymentRequiredResponse ÔÇö injects data into 402 responses
  • ResourceServerExtension.enrichSettlementResponse ÔÇö injects data into the final settlement response

But when the resource server calls the facilitator's /settle endpoint, the facilitator has no
standardized way
to populate SettleResponse.extensions. The field exists in both the TypeScript
and Go SDKs (see PR #1808 which added it to Go), but there are no hooks to fill it on the
facilitator side.

This was identified by @alftom in issue #1802:

"One missing piece seems to be implementing something like
HTTPFacilitatorClient.registerExtension so facilitators can pass the payload back to the
resource server."

This PR is the infrastructure fix. The first consumer will be the facilitator-attestation
extension (separate PR), which uses this hook to sign and return EIP-712 attestations.

The Flow (before and after)

Before:

Client  ResourceServer  Facilitator /settle  bare SettleResponse
                                                  (no extension hooks; extensions always {})

After:

Client  ResourceServer  Facilitator /settle  SettleResponse
                                                  Ôåæ
                                        enrichSettleResponse hooks
                                        populate extensions field

Relationship to offer-receipt (PR #935)

PR #935 added ResourceServerExtension.enrichSettlementResponse for the resource server boundary.
This PR adds the symmetric hook at the facilitator boundary, completing the extension pipeline:

[Facilitator] enrichSettleResponse  SettleResponse.extensions
     [ResourceServer] enrichSettlementResponse  final response to client

Relationship to Go SDK parity (PR #1808)

PR #1808 added the Extensions field to Go's SettleResponse. This PR adds the hook mechanism
that populates that field on the TypeScript facilitator side.

Changes

packages/core/src/types/extensions.ts

  • Added FacilitatorSettleResultContext interface (exported) ÔÇö the context passed to the hook,
    containing paymentPayload, requirements, and result: SettleResponse.
  • Added enrichSettleResponse?: (context: FacilitatorSettleResultContext) => Promise<unknown>
    to FacilitatorExtension.

packages/core/src/facilitator/x402Facilitator.ts

  • In settle(): after afterSettleHooks complete, iterate registered extensions and call
    enrichSettleResponse for each. Extension data is merged into settleResult.extensions[key].
  • Error isolation: each hook is wrapped in try/catch; errors are logged with
    console.error and settlement continues ÔÇö a failing extension MUST NOT break the payment.

packages/core/test/unit/facilitator/x402Facilitator.extensions.test.ts (new)

12 new unit tests covering the contract described in the Tests section below. All 468 existing @x402/core tests continue to pass.

Design Decisions

No declaration parameter (unlike ResourceServerExtension)

ResourceServerExtension.enrichSettlementResponse(declaration, context) takes a declaration
because the resource server has per-route extension declarations. Facilitator extensions are
global (registered once on the facilitator instance) so there is no per-request declaration.
The hook takes only context.

FacilitatorSettleResultContext defined in extensions.ts

The context type is defined alongside the interface that uses it (in types/extensions.ts),
following the existing precedent where SettleResultContext is defined in x402ResourceServer.ts
and re-exported through extensions.ts. The local FacilitatorSettleResultContext in
x402Facilitator.ts (used for lifecycle hooks) is structurally compatible and remains unchanged.

Hook placement: after afterSettleHooks, before return

Extension enrichment runs after all lifecycle hooks complete. This ensures the lifecycle hook
pipeline (e.g., audit logging in onAfterSettle) sees the un-enriched response, while the
enriched response (with signed extension data) is what gets returned to the caller.

enrichSettleResponse is NOT called when settlement throws

If the scheme facilitator throws (e.g., on-chain tx failure), control jumps to the catch block
and extension hooks are skipped. Extensions only run on successful settlements.

enrichSettleResponse IS called when scheme returns success: false

If the scheme returns a SettleResponse with success: false without throwing, the hook runs.
This is consistent with how afterSettleHooks behave. Extension implementations that only want
to run on successful settlements should guard with if (!ctx.result.success) return undefined;.

Tests

# Description Status
1 registerExtension stores extension by key 
2 enrichSettleResponse called with correct context on success 
3 enrichSettleResponse return value appears in SettleResponse.extensions[key] 
4 Multiple extensions run in registration order, both populate extensions 
5 Hook throws  error logged, settlement still succeeds 
6 Extension without enrichSettleResponse  extensions unchanged 
7 Settlement throws  enrichSettleResponse not called 
8 extensions field survives JSON round-trip 
9 Context carries correct txHash and network from settlement result 
10 End-to-end: registered extension adds extensions field 
11 Hook returning undefined does not create empty extensions[key] 
12 success: false result: hook called, extension can inspect and skip 

Checklist

  • Adds FacilitatorExtension.enrichSettleResponse hook
  • Adds FacilitatorSettleResultContext context type (exported)
  • Hook called in x402Facilitator.settle() after successful settlement
  • Error isolation: failing hook logs + does not abort settlement
  • Hook NOT called on thrown settlement errors
  • extensions field preserved through Zod parse (existing settleResponseSchema already handles this)
  • All new tests pass (12 tests)
  • All existing @x402/core tests pass (468 total, 0 regressions)
  • @x402/core builds cleanly (TypeScript + DTS)
  • Backwards compatible: facilitators without registerExtension unaffected
  • Does not modify offer-receipt extension
  • Does not add facilitator-attestation extension (separate PR)

What This Enables

Once this PR merges, a facilitator can produce signed attestations like:

import { x402Facilitator } from "@x402/core/facilitator";

const facilitator = new x402Facilitator();

facilitator.registerExtension({
  key: "facilitator-attestation",
  enrichSettleResponse: async (ctx) => {
    if (!ctx.result.success) return undefined;
    return {
      attestation: await signAttestation(ctx.result, signingKey),
    };
  },
});

The resource server receives the enriched SettleResponse (via HTTPFacilitatorClient.settle),
and the extensions["facilitator-attestation"] data flows through to the client via the existing
PAYMENT-RESPONSE header.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 16, 2026

@javierpmateos is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added typescript sdk Changes to core v2 packages labels May 16, 2026
@javierpmateos javierpmateos force-pushed the feat/facilitator-extension-hooks branch 2 times, most recently from 26ff814 to 98b14a6 Compare May 16, 2026 22:04
@alftom
Copy link
Copy Markdown
Contributor

alftom commented May 18, 2026

@javierpmateos do you have an extension spec to add to the PR? It would go in 'specs/extensions` I would think.

In my view this PR could cover two paths:

  1. Information sent from the facilitator to the resource server that the resource server will put in the extensions.offer-receipt.signature
  2. A self-contained payload signed by the facilitator and sent from the facilitator to the resource server that the resource server will relay to the client using some yet-defined mechanism (probably a separate extensions field rather than inside extensions.offer-receipt).

Given #1802 it looks like you're targeting #2 but it should be clear in a spec.

@javierpmateos javierpmateos force-pushed the feat/facilitator-extension-hooks branch from 98b14a6 to 9a2ba01 Compare May 18, 2026 18:31
@github-actions github-actions Bot added the specs Spec changes or additions label May 18, 2026
@javierpmateos
Copy link
Copy Markdown
Contributor Author

Thanks @alftom ... good call. I've added the extension spec at specs/extensions/extension-facilitator-attestation.md.

To clarify: this targets path #2 — a self-contained payload signed by the facilitator, relayed by the resource server to the client via a separate extensions["facilitator-attestation"] field. It does not modify or sit inside extensions["offer-receipt"].

The two extensions compose but stay independent: offer-receipt proves delivery (server-signed), facilitator-attestation proves settlement (facilitator-signed). Different signer, different claim, different privacy surface — as @jithinraj recommended in #1802.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk Changes to core v2 packages specs Spec changes or additions typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants