diff --git a/examples/typescript/x402/README.md b/examples/typescript/x402/README.md index 5d09a1f64..983b1c288 100644 --- a/examples/typescript/x402/README.md +++ b/examples/typescript/x402/README.md @@ -8,6 +8,9 @@ the CDP SDK adds on top: - **Hosted facilitator** — `createCdpFacilitatorClient()` is a drop-in for a self-hosted facilitator. - **Spend controls** — per-payment and rolling caps, network/asset/payee allowlists, and an approaching-limit callback. +- **Builder codes** — optional `builderCode` on `CdpX402Client` / `createX402Server` for + [on-chain attribution](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) + (`s` on the client, `a` on the server). ## Prerequisites diff --git a/examples/typescript/x402/servers/express/x402.config.schema.json b/examples/typescript/x402/servers/express/x402.config.schema.json index 4116a70fa..31be9dd2a 100644 --- a/examples/typescript/x402/servers/express/x402.config.schema.json +++ b/examples/typescript/x402/servers/express/x402.config.schema.json @@ -27,6 +27,11 @@ "enum": ["production", "development"], "description": "Deployment environment controlling default networks. `production` (default) → Base mainnet + Solana mainnet; `development` → Base Sepolia + Solana Devnet. Falls back to the CDP_X402_SERVER_ENVIRONMENT env var." }, + "builderCode": { + "type": "string", + "pattern": "^[a-z0-9_]{1,32}$", + "description": "Optional ERC-8021 builder-code app attribution (`a`). When set, every route with an EVM payment option advertises the builder-code extension with this code; Solana-only routes are skipped. Omit to leave the extension unset. Override per-route via extensions[\"builder-code\"]." + }, "payToConfig": { "description": "How receiver payTo addresses are resolved. Defaults to { \"type\": \"eoa\" }.", "oneOf": [ @@ -129,7 +134,7 @@ }, "extensions": { "type": "object", - "description": "Extension overrides. CDP extensions are auto-injected; use this to override the auto-generated Bazaar declaration with richer discovery metadata." + "description": "Extension overrides. CDP extensions (gas-sponsoring, bazaar, and builder-code on EVM routes when builderCode is set) are auto-injected; use this to override the auto-generated Bazaar or builder-code declaration." } }, "required": ["price"] diff --git a/typescript/.changeset/x402-builder-code.md b/typescript/.changeset/x402-builder-code.md new file mode 100644 index 000000000..700fe57ce --- /dev/null +++ b/typescript/.changeset/x402-builder-code.md @@ -0,0 +1,5 @@ +--- +"@coinbase/cdp-sdk": minor +--- + +Add optional `builderCode` on `CdpX402Client` and `createX402Server` to auto-attach the x402 `builder-code` extension for ERC-8021 on-chain attribution. The client accepts a single service code or an array of them; the server advertises its app code on every route with an EVM payment option. diff --git a/typescript/packages/cdp-sdk/README.md b/typescript/packages/cdp-sdk/README.md index 4d9dd95b1..8df775bb7 100644 --- a/typescript/packages/cdp-sdk/README.md +++ b/typescript/packages/cdp-sdk/README.md @@ -1694,6 +1694,14 @@ const client = new CdpX402Client({ }); ``` +To attribute payments via the [builder-code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) extension, pass an optional `builderCode` (1–32 lowercase alphanumeric / underscore characters, or an array of them when several participants share attribution). The client attaches them as service codes (`s`) on payment payloads; omit the field to leave the extension unset: + +```typescript +const client = new CdpX402Client({ builderCode: "my_client" }); +``` + +Service codes are only attached when the resource server advertises the `builder-code` extension in its `PaymentRequired` response — against servers that do not, the codes are dropped and the payment proceeds unattributed. + ### Apply spend controls Attach `spendControls` to `CdpX402Client` to enforce per-payment and cumulative caps, restrict networks/assets/payees, and receive callbacks as spend approaches a limit. A blocked payment throws a `SpendControlError` with a machine-readable `code`. @@ -1736,6 +1744,15 @@ app.use(paymentMiddlewareFromHTTPServer(server)); console.log("Receiving EVM payments at", server.payToEvmAddress); ``` +To attribute settled payments to your app via [builder-code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md), pass optional `builderCode`. Every route with an EVM payment option then advertises the extension with that app code (`a`); omit it to leave the extension unset. Solana-only routes are skipped, since the attribution suffix is ERC-8021 EVM calldata: + +```typescript +const server = await createX402Server({ + builderCode: "my_app", + routes: { "GET /report": { price: "$0.01", description: "AI-generated report" } }, +}); +``` + ### Use the CDP-hosted facilitator `createCdpFacilitatorClient` returns a CDP-authenticated `HTTPFacilitatorClient` that verifies and settles payments through the CDP-hosted facilitator. It is a drop-in replacement for a self-hosted facilitator and only needs API-key credentials (no wallet secret). diff --git a/typescript/packages/cdp-sdk/src/e2e.test.ts b/typescript/packages/cdp-sdk/src/e2e.test.ts index 77c1ed081..d2133620e 100644 --- a/typescript/packages/cdp-sdk/src/e2e.test.ts +++ b/typescript/packages/cdp-sdk/src/e2e.test.ts @@ -60,6 +60,7 @@ import { SpendPermission } from "./spend-permissions/types.js"; import { HTTPFacilitatorClient } from "@x402/core/http"; import type { PaymentPayload, PaymentRequired, PaymentRequirements } from "@x402/core/types"; import { VerifyError } from "@x402/core/types"; +import { declareBuilderCodeExtension } from "@x402/extensions/builder-code"; import { wrapFetchWithPayment } from "@x402/fetch"; import { CdpX402Client } from "./x402/client.js"; import { createCdpFacilitatorClient } from "./x402/facilitator.js"; @@ -4650,9 +4651,12 @@ describe("x402 signing E2E Tests", () => { }); describe("CdpX402Client E2E Tests", () => { - it("CdpX402Client creates a payment payload that the CDP facilitator verifies", async () => { + it("CdpX402Client creates a payment payload with builder-code attribution that the CDP facilitator verifies", async () => { await ensureX402DefaultEvmPayerFunded(); - const client = new CdpX402Client({ environment: "development" }); + const client = new CdpX402Client({ + environment: "development", + builderCode: "cdp_sdk_e2e_client", + }); const facilitator = createCdpFacilitatorClient(); const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); @@ -4675,11 +4679,18 @@ describe("CdpX402Client E2E Tests", () => { extra: { name, version }, }, ], + // Mirrors what createX402Server advertises when `builderCode` is set. + extensions: { "builder-code": declareBuilderCodeExtension("cdp_sdk_e2e_app") }, }; const payment = await client.createPaymentPayload(paymentRequired); const result = await facilitator.verify(payment, payment.accepted); + // The app code is echoed from the server declaration, the service code comes + // from the client's `builderCode`; the facilitator accepts the enriched echo. + expect(payment.extensions?.["builder-code"]).toMatchObject({ + info: { a: "cdp_sdk_e2e_app", s: ["cdp_sdk_e2e_client"] }, + }); expect(result.isValid).toBe(true); }, 180_000); diff --git a/typescript/packages/cdp-sdk/src/x402/builder-code.ts b/typescript/packages/cdp-sdk/src/x402/builder-code.ts new file mode 100644 index 000000000..7fdcaeff4 --- /dev/null +++ b/typescript/packages/cdp-sdk/src/x402/builder-code.ts @@ -0,0 +1,48 @@ +/* + * Validation helpers for the x402 builder-code extension. + * + * Upstream validates codes with `BUILDER_CODE_PATTERN.test()`, which coerces its + * argument — `42` and `["my_app"]` both stringify into something the pattern + * accepts. Config values reaching the SDK are not always typed (a `configPath` + * file is untyped JSON), so the type is checked here before the pattern runs. + */ + +import { BUILDER_CODE_PATTERN } from "@x402/extensions/builder-code"; + +/** Shared tail of every builder-code rejection message. */ +const CODE_REQUIREMENT = + "Must be a string of 1-32 characters, lowercase alphanumeric and underscores only."; + +/** + * Asserts that a value is a syntactically valid builder code. + * + * @param code - Candidate builder code, possibly from untyped JSON. + * @throws If `code` is not a string matching `^[a-z0-9_]{1,32}$`. + */ +export function assertBuilderCode(code: unknown): asserts code is string { + if (typeof code !== "string" || !BUILDER_CODE_PATTERN.test(code)) { + throw new Error(`Invalid builder code: ${JSON.stringify(code)}. ${CODE_REQUIREMENT}`); + } +} + +/** + * Normalizes a client `builderCode` config value into a non-empty array of + * validated service codes. + * + * @param builderCode - A single service code, or an array of them. + * @returns The validated service codes. + * @throws If any code is invalid, or if the array is empty — an empty array + * would otherwise register an extension that attaches no attribution. + */ +export function toServiceBuilderCodes(builderCode: unknown): string[] { + const codes = Array.isArray(builderCode) ? builderCode : [builderCode]; + if (codes.length === 0) { + throw new Error( + "Invalid builder code: []. Supply at least one code, or omit builderCode to leave the extension unset.", + ); + } + for (const code of codes) { + assertBuilderCode(code); + } + return codes; +} diff --git a/typescript/packages/cdp-sdk/src/x402/client.extensions.test.ts b/typescript/packages/cdp-sdk/src/x402/client.extensions.test.ts new file mode 100644 index 000000000..2940dfa41 --- /dev/null +++ b/typescript/packages/cdp-sdk/src/x402/client.extensions.test.ts @@ -0,0 +1,45 @@ +/* + * Extension-registry behavior of CdpX402Client against the real `x402Client` + * base class. `client.test.ts` mocks `@x402/core/client`, so it cannot observe + * how same-key registrations collapse. + */ + +import { BuilderCodeClientExtension } from "@x402/extensions/builder-code"; +import { describe, expect, it } from "vitest"; + +import { CdpX402Client } from "./client.js"; + +import type { ClientExtension } from "@x402/core/client"; + +/** + * Collects the builder-code extensions currently registered on a client. + * + * @param client - Client to inspect. + * @returns Every registered extension keyed `"builder-code"`. + */ +function builderCodeExtensions(client: CdpX402Client): ClientExtension[] { + return client.getExtensions().filter(extension => extension.key === "builder-code"); +} + +describe("CdpX402Client extension registry", () => { + it("registers no builder-code extension when builderCode is omitted", () => { + expect(builderCodeExtensions(new CdpX402Client())).toHaveLength(0); + }); + + it("registers exactly one builder-code extension from builderCode", () => { + const client = new CdpX402Client({ builderCode: "my_client" }); + + expect(builderCodeExtensions(client)).toHaveLength(1); + }); + + it("lets a manually registered builder-code extension replace the configured one", () => { + const client = new CdpX402Client({ builderCode: "my_client" }); + const custom = new BuilderCodeClientExtension("my_override"); + + client.registerExtension(custom); + + // The registry is keyed by extension key, so the caller's later + // registration replaces the one built in the constructor. + expect(builderCodeExtensions(client)).toEqual([custom]); + }); +}); diff --git a/typescript/packages/cdp-sdk/src/x402/client.test.ts b/typescript/packages/cdp-sdk/src/x402/client.test.ts index 10dd96ef0..e8d375892 100644 --- a/typescript/packages/cdp-sdk/src/x402/client.test.ts +++ b/typescript/packages/cdp-sdk/src/x402/client.test.ts @@ -9,6 +9,7 @@ const { mockRegister, mockRegisterV1, mockRegisterPolicy, + mockRegisterExtension, mockOnBeforePaymentCreation, mockOnAfterPaymentCreation, mockOnPaymentCreationFailure, @@ -23,6 +24,7 @@ const { const mockRegister = vi.fn(); const mockRegisterV1 = vi.fn(); const mockRegisterPolicy = vi.fn(); + const mockRegisterExtension = vi.fn().mockReturnThis(); const mockOnBeforePaymentCreation = vi.fn().mockReturnThis(); const mockOnAfterPaymentCreation = vi.fn().mockReturnThis(); const mockOnPaymentCreationFailure = vi.fn().mockReturnThis(); @@ -52,6 +54,7 @@ const { mockRegister, mockRegisterV1, mockRegisterPolicy, + mockRegisterExtension, mockOnBeforePaymentCreation, mockOnAfterPaymentCreation, mockOnPaymentCreationFailure, @@ -80,6 +83,10 @@ vi.mock("@x402/core/client", () => { return mockRegisterPolicy(...args); } + registerExtension(...args: unknown[]) { + return mockRegisterExtension(...args); + } + createPaymentPayload(...args: unknown[]) { return mockCreatePaymentPayload(...args); } @@ -153,6 +160,7 @@ import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; +import { BuilderCodeClientExtension } from "@x402/extensions/builder-code"; import { ExactSvmScheme } from "@x402/svm/exact/client"; import { ExactSvmSchemeV1 } from "@x402/svm/exact/v1/client"; @@ -230,6 +238,22 @@ function lastEvmRpcMap( | undefined; } +/** + * Runs the registered builder-code extension over a payload to read back the + * service codes it attaches — `BuilderCodeClientExtension` keeps them private. + * + * @returns The `s` service codes, or undefined if no extension was registered. + */ +async function enrichedServiceCodes(): Promise { + const extension = mockRegisterExtension.mock.calls.at(-1)?.[0] as + | BuilderCodeClientExtension + | undefined; + if (!extension) return undefined; + + const enriched = await extension.enrichPaymentPayload(mockPayload, mockPaymentRequired); + return (enriched.extensions?.["builder-code"] as { info: { s: string[] } }).info.s; +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe("CdpX402Client", () => { @@ -681,4 +705,72 @@ describe("CdpX402Client", () => { }); }); }); + + describe("builderCode", () => { + it("does not register the builder-code extension when builderCode is omitted", async () => { + const client = new CdpX402Client(); + await client.createPaymentPayload(mockPaymentRequired); + + expect(mockRegisterExtension).not.toHaveBeenCalled(); + }); + + it("registers BuilderCodeClientExtension with the configured service codes", async () => { + new CdpX402Client({ builderCode: "my_client" }); + + expect(mockRegisterExtension).toHaveBeenCalledTimes(1); + expect(await enrichedServiceCodes()).toEqual(["my_client"]); + }); + + it("registers all codes when builderCode is an array", async () => { + new CdpX402Client({ builderCode: ["my_client", "my_middleware"] }); + + expect(mockRegisterExtension).toHaveBeenCalledTimes(1); + expect(await enrichedServiceCodes()).toEqual(["my_client", "my_middleware"]); + }); + + it("rejects an invalid builderCode in the constructor, before any CDP I/O", () => { + expect(() => new CdpX402Client({ builderCode: "INVALID-CODE" })).toThrow( + /Invalid builder code/, + ); + + expect(MockCdpClient).not.toHaveBeenCalled(); + expect(mockRegisterExtension).not.toHaveBeenCalled(); + }); + + it("rejects an empty-string builderCode in the constructor instead of silently leaving it unset", () => { + expect(() => new CdpX402Client({ builderCode: "" })).toThrow(/Invalid builder code/); + }); + + it("rejects an array containing an invalid builderCode in the constructor", () => { + expect(() => new CdpX402Client({ builderCode: ["my_client", "Bad Code"] })).toThrow( + /Invalid builder code: "Bad Code"/, + ); + }); + + it("rejects a builderCode longer than 32 characters", () => { + expect(() => new CdpX402Client({ builderCode: "a".repeat(33) })).toThrow( + /Invalid builder code/, + ); + }); + + it("rejects an empty builderCode array rather than registering a code-less extension", () => { + expect(() => new CdpX402Client({ builderCode: [] })).toThrow(/Invalid builder code: \[\]/); + + expect(mockRegisterExtension).not.toHaveBeenCalled(); + }); + + /* + * `builderCode` is typed, but callers can still feed it untyped JSON, and the + * upstream pattern check coerces its argument — `42` stringifies into + * something the pattern accepts. + */ + it.each([ + ["a number", 42], + ["a nested array", [["my_client"]]], + ])("rejects %s as builderCode", (_label, builderCode) => { + expect(() => new CdpX402Client({ builderCode: builderCode as never })).toThrow( + /Invalid builder code: .*Must be a string/, + ); + }); + }); }); diff --git a/typescript/packages/cdp-sdk/src/x402/client.ts b/typescript/packages/cdp-sdk/src/x402/client.ts index 88abd6720..53d957745 100644 --- a/typescript/packages/cdp-sdk/src/x402/client.ts +++ b/typescript/packages/cdp-sdk/src/x402/client.ts @@ -12,6 +12,7 @@ import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; +import { BuilderCodeClientExtension } from "@x402/extensions/builder-code"; import { ExactSvmScheme } from "@x402/svm/exact/client"; import { ExactSvmSchemeV1 } from "@x402/svm/exact/v1/client"; @@ -20,6 +21,7 @@ import { fromCdpEvmAccount, fromCdpSmartWallet, } from "./account-signers.js"; +import { toServiceBuilderCodes } from "./builder-code.js"; import { baseMainnetCaip2, baseSepoliaCaip2, getDefaultEvmRpcUrls } from "./constants.js"; import { CdpClient } from "../client/cdp.js"; import { applySpendControls } from "./guardrails/apply.js"; @@ -99,6 +101,23 @@ export interface CdpX402ClientConfig { */ spendControls?: SpendControls; + /** + * Optional [builder code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) + * for on-chain attribution (`s` / service codes). Pass an array to attribute + * several participants, e.g. a client layered behind middleware. + * + * When set, registers the `builder-code` client extension so payment payloads + * include these codes. Codes only reach the payload for resource servers that + * advertise the `builder-code` extension in their `PaymentRequired` response; + * against servers that do not, the codes are dropped. + * + * Each code must match `^[a-z0-9_]{1,32}$`; invalid codes and an empty array + * are rejected by the constructor. Omit to leave the extension unset. A + * `builder-code` extension registered manually via `registerExtension` + * replaces the one built here. + */ + builderCode?: string | string[]; + /** * Deployment environment. Controls which Base network is prescribed by default. * @@ -402,6 +421,12 @@ export interface CdpX402WalletAddresses { * }, * }); * ``` + * + * @example + * ```typescript + * // Attribute payments to this client via the builder-code extension. + * const client = new CdpX402Client({ builderCode: "my_client" }); + * ``` */ export class CdpX402Client extends x402Client { private readonly _config: CdpX402ClientConfig | undefined; @@ -412,9 +437,21 @@ export class CdpX402Client extends x402Client { * Creates a CdpX402Client that initializes lazily on first payment. * * @param config - Optional configuration. Credentials fall back to environment variables. + * @throws If `config.builderCode` is an empty array, or contains a code that + * is not 1-32 lowercase alphanumeric / underscore characters. */ constructor(config?: CdpX402ClientConfig) { super(); + /* + * Registered here rather than during lazy initialization so that a malformed + * code throws before any CDP I/O, and so a `registerExtension("builder-code")` + * call by the caller takes precedence over the config. + */ + if (config?.builderCode !== undefined) { + this.registerExtension( + new BuilderCodeClientExtension(toServiceBuilderCodes(config.builderCode)), + ); + } this._config = config; } diff --git a/typescript/packages/cdp-sdk/src/x402/index.ts b/typescript/packages/cdp-sdk/src/x402/index.ts index f1acfbde7..e0e0dcf24 100644 --- a/typescript/packages/cdp-sdk/src/x402/index.ts +++ b/typescript/packages/cdp-sdk/src/x402/index.ts @@ -6,6 +6,7 @@ * - **Payment client**: `CdpX402Client` — pay for x402-protected APIs * - **Facilitator**: `createCdpFacilitatorClient` — CDP-hosted payment facilitator * - **Spend controls**: guardrails for autonomous agents + * - **Builder codes**: optional `builderCode` on client/server for ERC-8021 attribution * - **Signer adapters**: bridge CDP accounts into existing x402 setups * * ## Quick start @@ -79,6 +80,7 @@ export { CDP_EXTENSION_GAS_SPONSORING_EIP2612, CDP_EXTENSION_GAS_SPONSORING_ERC20_APPROVAL, CDP_EXTENSION_BAZAAR, + CDP_EXTENSION_BUILDER_CODE, CDP_SUPPORTED_EXTENSIONS, buildBazaarDeclaration, } from "./server-extensions.js"; diff --git a/typescript/packages/cdp-sdk/src/x402/server-extensions.ts b/typescript/packages/cdp-sdk/src/x402/server-extensions.ts index a5d4e4d3d..13e429fcc 100644 --- a/typescript/packages/cdp-sdk/src/x402/server-extensions.ts +++ b/typescript/packages/cdp-sdk/src/x402/server-extensions.ts @@ -1,15 +1,18 @@ /** * CDP-opinionated extension wiring for the x402 payment protocol. * - * `createX402Server` automatically advertises all CDP extensions on every - * route. Gas-sponsoring extensions are static (presence of key is enough). - * Bazaar is built per-route from the route key and any user-provided overrides. - * - * | Key | Auto-injected | Notes | - * |-----|---------------|-------| - * | `"eip2612GasSponsoring"` | ✓ | Sponsored Permit2 via EIP-2612 permit | - * | `"erc20ApprovalGasSponsoring"` | ✓ | Sponsored ERC-20 approve tx | - * | `"bazaar"` | ✓ | Minimal discovery metadata built from route pattern | + * `createX402Server` advertises the extensions below on the routes each one + * applies to. Gas-sponsoring extensions are static (presence of the key is + * enough) and EVM-only. Bazaar is built per-route from the route key and any + * user-provided overrides. Builder code is EVM-only and injected only when + * `builderCode` is set on the server config. + * + * | Key | Auto-injected on | Notes | + * |-----|------------------|-------| + * | `"eip2612GasSponsoring"` | EVM routes | Sponsored Permit2 via EIP-2612 permit | + * | `"erc20ApprovalGasSponsoring"` | EVM routes | Sponsored ERC-20 approve tx | + * | `"bazaar"` | every route | Minimal discovery metadata built from route pattern | + * | `"builder-code"` | EVM routes, when `builderCode` set | ERC-8021 app attribution (`a`) | * * Users who need richer Bazaar metadata (queryParams, body example, output * schema, etc.) can override by setting `extensions.bazaar` on the route — @@ -19,6 +22,7 @@ import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; import { bazaarResourceServerExtension } from "@x402/extensions/bazaar"; +import { BUILDER_CODE, builderCodeResourceServerExtension } from "@x402/extensions/builder-code"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import type { ResourceServerExtension, Network, SchemeNetworkServer } from "@x402/core/types"; @@ -56,6 +60,15 @@ export const CDP_EXTENSION_GAS_SPONSORING_ERC20_APPROVAL = "erc20ApprovalGasSpon */ export const CDP_EXTENSION_BAZAAR = "bazaar" as const; +/** + * Extension key for [builder-code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) + * on-chain attribution (ERC-8021 Schema 2). + * + * Injected by `createX402Server` on EVM routes only when `builderCode` is set on + * the server config. Declares the app code (`a`) in `PaymentRequired.extensions`. + */ +export const CDP_EXTENSION_BUILDER_CODE = BUILDER_CODE; + /* * --------------------------------------------------------------------------- * Auto-injected extension set @@ -71,8 +84,9 @@ export const CDP_EXTENSION_BAZAAR = "bazaar" as const; * only activates the path when `requirements.extra.assetTransferMethod` is * `"permit2"`, so the declarations are harmless for EIP-3009 and Solana routes. * - * Bazaar is NOT in this set — it is injected separately because it requires - * per-route metadata (HTTP method, path template) to build its declaration. + * Bazaar and builder-code are NOT in this set — Bazaar needs per-route metadata + * (HTTP method, path template), and builder-code is opt-in via `builderCode` + * and carries the app code in its declaration. */ export const CDP_SUPPORTED_EXTENSIONS: Record = { [CDP_EXTENSION_GAS_SPONSORING_EIP2612]: {}, @@ -216,7 +230,7 @@ export function getCdpDefaultSchemes(): CdpSchemeRegistration[] { * } * ``` * - * @returns Array of `ResourceServerExtension` registrations for gas-sponsoring and Bazaar. + * @returns Array of `ResourceServerExtension` registrations for gas-sponsoring, Bazaar, and builder-code. */ export function getCdpExtensionRegistrations(): ResourceServerExtension[] { return [ @@ -229,5 +243,6 @@ export function getCdpExtensionRegistrations(): ResourceServerExtension[] { enrichPaymentRequiredResponse: async declaration => declaration ?? {}, }, bazaarResourceServerExtension, + builderCodeResourceServerExtension, ]; } diff --git a/typescript/packages/cdp-sdk/src/x402/server.test.ts b/typescript/packages/cdp-sdk/src/x402/server.test.ts index 7dfea88ab..1312cb613 100644 --- a/typescript/packages/cdp-sdk/src/x402/server.test.ts +++ b/typescript/packages/cdp-sdk/src/x402/server.test.ts @@ -14,6 +14,7 @@ import { CDP_EXTENSION_GAS_SPONSORING_EIP2612, CDP_EXTENSION_GAS_SPONSORING_ERC20_APPROVAL, CDP_EXTENSION_BAZAAR, + CDP_EXTENSION_BUILDER_CODE, CDP_SUPPORTED_EXTENSIONS, buildBazaarDeclaration, getCdpDefaultSchemes, @@ -23,6 +24,7 @@ import { validateDiscoveryExtension, validateDiscoveryExtensionSpec, } from "@x402/extensions/bazaar"; +import { declareBuilderCodeExtension } from "@x402/extensions/builder-code"; // --------------------------------------------------------------------------- // Mocks @@ -906,6 +908,7 @@ describe("X402Server extension registration", () => { expect(registeredKeys).toContain(CDP_EXTENSION_GAS_SPONSORING_EIP2612); expect(registeredKeys).toContain(CDP_EXTENSION_GAS_SPONSORING_ERC20_APPROVAL); expect(registeredKeys).toContain(CDP_EXTENSION_BAZAAR); + expect(registeredKeys).toContain(CDP_EXTENSION_BUILDER_CODE); }); it("registers exactly the extensions from getCdpExtensionRegistrations()", async () => { @@ -1087,6 +1090,177 @@ describe("X402Server auto-injects gas-sponsoring extensions", () => { }); }); +describe("X402Server auto-injects the builder-code extension", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + mockHttpInitialize.mockResolvedValue(undefined); + process.env.CDP_API_KEY_ID = "env-key-id"; + process.env.CDP_API_KEY_SECRET = "env-key-secret"; + process.env.CDP_WALLET_SECRET = "env-wallet-secret"; + }); + + afterEach(() => { + process.env = { ...savedEnv }; + }); + + /** + * Reads the routes `createX402Server` handed to the HTTP resource server. + * + * @returns Resolved routes keyed by pattern, with their extension declarations. + */ + async function passedRoutes(): Promise< + Record }> + > { + const { x402HTTPResourceServer } = await import("@x402/core/server"); + return vi.mocked(x402HTTPResourceServer).mock.calls[0]![1] as unknown as Record< + string, + { extensions: Record } + >; + } + + it("does NOT inject builder-code when builderCode is omitted", async () => { + await createX402Server({ + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }); + + const routes = await passedRoutes(); + expect(routes["GET /report"].extensions[CDP_EXTENSION_BUILDER_CODE]).toBeUndefined(); + }); + + it("injects builder-code on every EVM route when builderCode is set", async () => { + await createX402Server({ + builderCode: "my_app", + routes: { + "GET /a": { price: "$0.01", networks: ["eip155:8453"] }, + "GET /b": { price: "$0.02", networks: ["eip155:8453"] }, + }, + }); + + const routes = await passedRoutes(); + const expected = declareBuilderCodeExtension("my_app"); + for (const pattern of ["GET /a", "GET /b"]) { + const builderCode = routes[pattern].extensions[CDP_EXTENSION_BUILDER_CODE]; + expect(builderCode).toEqual(expected); + expect(builderCode.info.a).toBe("my_app"); + } + }); + + it("does NOT inject builder-code on a Solana-only route", async () => { + await createX402Server({ + builderCode: "my_app", + routes: { + "GET /svm": { price: "$0.01", networks: ["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"] }, + "GET /evm": { price: "$0.01", networks: ["eip155:8453"] }, + }, + }); + + const routes = await passedRoutes(); + expect(routes["GET /svm"].extensions[CDP_EXTENSION_BUILDER_CODE]).toBeUndefined(); + expect(routes["GET /evm"].extensions[CDP_EXTENSION_BUILDER_CODE]).toBeDefined(); + }); + + it("user-provided builder-code declaration overrides the auto-injected one", async () => { + const override = declareBuilderCodeExtension("other_app"); + + await createX402Server({ + builderCode: "my_app", + routes: { + "GET /report": { + price: "$0.01", + networks: ["eip155:8453"], + extensions: { [CDP_EXTENSION_BUILDER_CODE]: override }, + }, + }, + }); + + const routes = await passedRoutes(); + expect(routes["GET /report"].extensions[CDP_EXTENSION_BUILDER_CODE]).toBe(override); + }); + + it("injects a builderCode supplied via configPath", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ builderCode: "file_app" })); + + await createX402Server({ + configPath: "./x402.config.json", + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }); + + const routes = await passedRoutes(); + expect(routes["GET /report"].extensions[CDP_EXTENSION_BUILDER_CODE].info.a).toBe("file_app"); + }); + + it("inline builderCode wins over the one in configPath", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ builderCode: "file_app" })); + + await createX402Server({ + builderCode: "inline_app", + configPath: "./x402.config.json", + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }); + + const routes = await passedRoutes(); + expect(routes["GET /report"].extensions[CDP_EXTENSION_BUILDER_CODE].info.a).toBe("inline_app"); + }); + + it("rejects an invalid builderCode before provisioning wallets", async () => { + const { CdpClient } = await import("../client/cdp.js"); + + await expect( + createX402Server({ + builderCode: "INVALID-CODE", + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }), + ).rejects.toThrow(/Invalid builder code/); + + expect(CdpClient).not.toHaveBeenCalled(); + }); + + it("rejects an empty-string builderCode at create time instead of silently leaving it unset", async () => { + await expect( + createX402Server({ + builderCode: "", + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }), + ).rejects.toThrow(/Invalid builder code/); + }); + + it("rejects a builderCode supplied via configPath", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ builderCode: "Bad Code" })); + + await expect( + createX402Server({ + configPath: "./x402.config.json", + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }), + ).rejects.toThrow(/Invalid builder code: "Bad Code"/); + }); + + /* + * A config file is untyped JSON, and the upstream pattern check coerces its + * argument — `42` and `["my_app"]` both stringify into something the pattern + * accepts, so they must be rejected on type before reaching it. + */ + it.each([ + ["a number", 42], + ["a single-element array", ["my_app"]], + ])("rejects %s as builderCode from configPath", async (_label, builderCode) => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ builderCode })); + + await expect( + createX402Server({ + configPath: "./x402.config.json", + routes: { "GET /report": { price: "$0.01", networks: ["eip155:8453"] } }, + }), + ).rejects.toThrow(/Invalid builder code: .*Must be a string/); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Fix 1: CDP_X402_SERVER_ENVIRONMENT // ───────────────────────────────────────────────────────────────────────────── diff --git a/typescript/packages/cdp-sdk/src/x402/server.ts b/typescript/packages/cdp-sdk/src/x402/server.ts index 8572c2cd8..fb748ea8a 100644 --- a/typescript/packages/cdp-sdk/src/x402/server.ts +++ b/typescript/packages/cdp-sdk/src/x402/server.ts @@ -68,7 +68,9 @@ import { readFile } from "node:fs/promises"; import { x402ResourceServer, x402HTTPResourceServer } from "@x402/core/server"; +import { declareBuilderCodeExtension } from "@x402/extensions/builder-code"; +import { assertBuilderCode } from "./builder-code.js"; import { baseMainnetCaip2, baseSepoliaCaip2, @@ -81,6 +83,7 @@ import { getCdpExtensionRegistrations, CDP_SUPPORTED_EXTENSIONS, CDP_EXTENSION_BAZAAR, + CDP_EXTENSION_BUILDER_CODE, buildBazaarDeclaration, } from "./server-extensions.js"; import { findSmartAccountByOwner, isOwnerAlreadyHasSmartWalletError } from "./smart-account.js"; @@ -88,6 +91,7 @@ import { CdpClient } from "../client/cdp.js"; import type { RoutesConfig, RouteConfig } from "@x402/core/server"; import type { Network } from "@x402/core/types"; +import type { BuilderCodeRequiredExtension } from "@x402/extensions/builder-code"; import type { Address } from "viem"; /* @@ -196,9 +200,10 @@ export interface CdpRouteConfig { /** * Extension overrides for this route. * - * All three CDP extensions (`eip2612GasSponsoring`, `erc20ApprovalGasSponsoring`, - * and `bazaar`) are injected automatically. Use this field to override the - * auto-generated Bazaar declaration with richer discovery metadata. + * Gas-sponsoring and `bazaar` are injected automatically. When the server + * config sets `builderCode`, `builder-code` is injected too on EVM routes. + * Use this field to override the auto-generated Bazaar or builder-code + * declaration. */ extensions?: Record; } @@ -294,6 +299,17 @@ export interface CdpX402ServerConfig { * own addresses without provisioning a CDP wallet. */ payToConfig?: PayToConfig; + /** + * Optional [builder code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) + * for on-chain attribution (`a` / app code). + * + * When set, every route with an EVM (`eip155:*`) payment option advertises the + * `builder-code` extension with this app code; Solana-only routes are skipped + * because the attribution suffix is ERC-8021 EVM calldata. Must match + * `^[a-z0-9_]{1,32}$`. Omit to leave the extension unset. Override per-route + * via `extensions["builder-code"]`. + */ + builderCode?: string; /** * Payment-protected routes served by this server. * @@ -743,22 +759,31 @@ function routeHasEvmAccept(route: RouteConfig): boolean { /** * Merges CDP auto-injected extensions into a resolved route config. Gas-sponsoring - * extensions are added only to routes with an EVM (`eip155:*`) payment option - * (they are meaningless for Solana-only routes); the Bazaar declaration is built - * from the route pattern for all routes. User-provided `route.extensions` always win. + * extensions and builder-code are added only to routes with an EVM (`eip155:*`) + * payment option — gas sponsoring is meaningless for Solana-only routes, and + * builder-code attribution is an ERC-8021 EVM calldata suffix. The Bazaar + * declaration is built from the route pattern for all routes. User-provided + * `route.extensions` always win. * * @param pattern - Route key (e.g. `"GET /report"`) used to derive Bazaar metadata. * @param route - Resolved x402 `RouteConfig` to augment with CDP extensions. + * @param builderCode - Optional pre-validated builder-code declaration to advertise. * @returns A new `RouteConfig` with CDP extensions merged in. */ -function withAutoInjectedExtensions(pattern: string, route: RouteConfig): RouteConfig { +function withAutoInjectedExtensions( + pattern: string, + route: RouteConfig, + builderCode?: BuilderCodeRequiredExtension, +): RouteConfig { const bazaar = parseRouteKeyForBazaar(pattern); + const hasEvmAccept = routeHasEvmAccept(route); return { ...route, extensions: { - ...(routeHasEvmAccept(route) && CDP_SUPPORTED_EXTENSIONS), + ...(hasEvmAccept && CDP_SUPPORTED_EXTENSIONS), ...(bazaar && { [CDP_EXTENSION_BAZAAR]: buildBazaarDeclaration(bazaar.method, bazaar.path) }), + ...(builderCode && hasEvmAccept && { [CDP_EXTENSION_BUILDER_CODE]: builderCode }), ...route.extensions, }, }; @@ -773,6 +798,7 @@ function withAutoInjectedExtensions(pattern: string, route: RouteConfig): RouteC * @param evmAddress - EVM receiver address for `eip155:*` payment options (`""` when none). * @param svmAddress - Solana receiver address for `solana:*` payment options (`""` when none). * @param environment - Deployment environment controlling default network selection. + * @param builderCode - Optional builder-code declaration injected on every EVM route. * @returns A fully resolved `RoutesConfig` ready to pass to an HTTP resource server. */ function resolveRoutes( @@ -780,6 +806,7 @@ function resolveRoutes( evmAddress: Address | "", svmAddress: string, environment: "production" | "development", + builderCode?: BuilderCodeRequiredExtension, ): RoutesConfig { const result: Record = {}; const available: NetworkFamilies = { evm: evmAddress !== "", svm: svmAddress !== "" }; @@ -789,7 +816,7 @@ function resolveRoutes( "accepts" in route ? fillX402RoutePayTo(route, evmAddress, svmAddress) : convertCdpRoute(route, evmAddress, svmAddress, environment, available); - result[pattern] = withAutoInjectedExtensions(pattern, resolved); + result[pattern] = withAutoInjectedExtensions(pattern, resolved, builderCode); } return result; @@ -919,11 +946,19 @@ export class X402Server extends x402HTTPResourceServer { }; } - // 2. Validate routes before doing any I/O (fail fast before wallet provisioning). + /* + * 2. Validate routes and builder code before doing any I/O (fail fast + * before wallet provisioning). + */ const routes = merged.routes; if (!routes || Object.keys(routes).length === 0) { throw new Error("createX402Server requires at least one payment route."); } + let builderCodeDeclaration: BuilderCodeRequiredExtension | undefined; + if (merged.builderCode !== undefined) { + assertBuilderCode(merged.builderCode); + builderCodeDeclaration = declareBuilderCodeExtension(merged.builderCode); + } // 3. Resolve credentials and environment (config → CDP_* env var fallbacks). const credentials = resolveServerCredentials(merged); @@ -987,7 +1022,13 @@ export class X402Server extends x402HTTPResourceServer { } // 6. Resolve routes (simplified CDP format or full x402 format). - const resolvedRoutes = resolveRoutes(routes, evmAddress, svmAddress, environment); + const resolvedRoutes = resolveRoutes( + routes, + evmAddress, + svmAddress, + environment, + builderCodeDeclaration, + ); // 7. Construct and initialize — syncs supported schemes with the facilitator. const instance = new X402Server( diff --git a/typescript/packages/cdp-sdk/tsconfig.cjs.json b/typescript/packages/cdp-sdk/tsconfig.cjs.json index bc83a4c98..84e9543f5 100644 --- a/typescript/packages/cdp-sdk/tsconfig.cjs.json +++ b/typescript/packages/cdp-sdk/tsconfig.cjs.json @@ -28,6 +28,9 @@ "../../node_modules/@x402/evm/dist/cjs/batch-settlement/client/index.d.ts" ], "@x402/extensions/bazaar": ["../../node_modules/@x402/extensions/dist/cjs/bazaar/index.d.ts"], + "@x402/extensions/builder-code": [ + "../../node_modules/@x402/extensions/dist/cjs/builder-code/index.d.ts" + ], "@x402/fetch": ["../../node_modules/@x402/fetch/dist/cjs/index.d.ts"], "@x402/svm/exact/client": ["../../node_modules/@x402/svm/dist/cjs/exact/client/index.d.ts"], "@x402/svm/exact/v1/client": [