Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/typescript/x402/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"]
Expand Down
5 changes: 5 additions & 0 deletions typescript/.changeset/x402-builder-code.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions typescript/packages/cdp-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we should change this and always attribute the client regardless of the server, dont see a good reason to drop

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


### 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`.
Expand Down Expand Up @@ -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).
Expand Down
15 changes: 13 additions & 2 deletions typescript/packages/cdp-sdk/src/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() });
Expand All @@ -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);

Expand Down
48 changes: 48 additions & 0 deletions typescript/packages/cdp-sdk/src/x402/builder-code.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions typescript/packages/cdp-sdk/src/x402/client.extensions.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
92 changes: 92 additions & 0 deletions typescript/packages/cdp-sdk/src/x402/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
mockRegister,
mockRegisterV1,
mockRegisterPolicy,
mockRegisterExtension,
mockOnBeforePaymentCreation,
mockOnAfterPaymentCreation,
mockOnPaymentCreationFailure,
Expand All @@ -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();
Expand Down Expand Up @@ -52,6 +54,7 @@ const {
mockRegister,
mockRegisterV1,
mockRegisterPolicy,
mockRegisterExtension,
mockOnBeforePaymentCreation,
mockOnAfterPaymentCreation,
mockOnPaymentCreationFailure,
Expand Down Expand Up @@ -80,6 +83,10 @@ vi.mock("@x402/core/client", () => {
return mockRegisterPolicy(...args);
}

registerExtension(...args: unknown[]) {
return mockRegisterExtension(...args);
}

createPaymentPayload(...args: unknown[]) {
return mockCreatePaymentPayload(...args);
}
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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<string[] | undefined> {
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", () => {
Expand Down Expand Up @@ -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/,
);
});
});
});
Loading
Loading