diff --git a/typescript/packages/extensions/package.json b/typescript/packages/extensions/package.json index 0437e33c5f..0b58e786f8 100644 --- a/typescript/packages/extensions/package.json +++ b/typescript/packages/extensions/package.json @@ -103,6 +103,16 @@ "types": "./dist/cjs/payment-identifier/index.d.ts", "default": "./dist/cjs/payment-identifier/index.js" } + }, + "./builder-code": { + "import": { + "types": "./dist/esm/builder-code/index.d.mts", + "default": "./dist/esm/builder-code/index.mjs" + }, + "require": { + "types": "./dist/cjs/builder-code/index.d.ts", + "default": "./dist/cjs/builder-code/index.js" + } } }, "files": [ diff --git a/typescript/packages/extensions/src/builder-code/cbor.ts b/typescript/packages/extensions/src/builder-code/cbor.ts new file mode 100644 index 0000000000..d45ba272e1 --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/cbor.ts @@ -0,0 +1,176 @@ +/** + * ERC-8021 Schema 2 CBOR encoding for builder code suffixes. + * + * Schema 2 suffix format: + * [cbor_data (variable)] [suffix_data_length (2 bytes)] [schema_id = 0x02 (1 byte)] [ERC-8021 marker (16 bytes)] + * + * CBOR payload uses single-letter keys: + * "a" — app builder code (string) + * "w" — wallet/facilitator builder code (string) + * "s" — service codes (string array) + */ + +import { type Hex } from "viem"; +import { ERC_8021_MARKER, SCHEMA_2_ID, type BuilderCodeExtensionData } from "./types"; + +/** + * Encodes a CBOR map from builder code extension data. + * + * Produces a minimal CBOR map with: + * - "a" key (major type 3, length 1) → string value (app/service code) + * - "w" key (major type 3, length 1) → string value (facilitator code) + * - "s" key (major type 3, length 1) → array of strings (related services) + * + * Uses hand-rolled CBOR to avoid adding a dependency. + */ +function encodeCborMap(data: BuilderCodeExtensionData): Uint8Array { + const entries: Uint8Array[] = []; + let mapSize = 0; + + if (data.a) { + mapSize++; + entries.push(encodeCborString("a")); + entries.push(encodeCborString(data.a)); + } + + if (data.w) { + mapSize++; + entries.push(encodeCborString("w")); + entries.push(encodeCborString(data.w)); + } + + if (data.s && data.s.length > 0) { + mapSize++; + entries.push(encodeCborString("s")); + entries.push(encodeCborArray(data.s)); + } + + // CBOR map header + const header = encodeCborMajorType(5, mapSize); // major type 5 = map + + const totalLength = header.length + entries.reduce((sum, e) => sum + e.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + + result.set(header, offset); + offset += header.length; + + for (const entry of entries) { + result.set(entry, offset); + offset += entry.length; + } + + return result; +} + +/** + * Encodes a CBOR text string (major type 3). + */ +function encodeCborString(value: string): Uint8Array { + const encoded = new TextEncoder().encode(value); + const header = encodeCborMajorType(3, encoded.length); // major type 3 = text string + const result = new Uint8Array(header.length + encoded.length); + result.set(header, 0); + result.set(encoded, header.length); + return result; +} + +/** + * Encodes a CBOR array of strings (major type 4). + */ +function encodeCborArray(values: string[]): Uint8Array { + const header = encodeCborMajorType(4, values.length); // major type 4 = array + const encodedValues = values.map(encodeCborString); + + const totalLength = header.length + encodedValues.reduce((sum, e) => sum + e.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + + result.set(header, offset); + offset += header.length; + + for (const encoded of encodedValues) { + result.set(encoded, offset); + offset += encoded.length; + } + + return result; +} + +/** + * Encodes a CBOR major type with an argument value. + * + * CBOR encoding rules: + * - 0-23: single byte (major type << 5 | value) + * - 24-255: two bytes (major type << 5 | 24, value) + * - 256-65535: three bytes (major type << 5 | 25, value high, value low) + */ +function encodeCborMajorType(majorType: number, value: number): Uint8Array { + const mt = majorType << 5; + + if (value <= 23) { + return new Uint8Array([mt | value]); + } + if (value <= 0xff) { + return new Uint8Array([mt | 24, value]); + } + if (value <= 0xffff) { + return new Uint8Array([mt | 25, (value >> 8) & 0xff, value & 0xff]); + } + + throw new Error(`CBOR value too large: ${value}`); +} + +/** + * Builds a complete ERC-8021 Schema 2 data suffix from builder code data. + * + * Format: [cbor_data][suffix_data_length (2 bytes)][schema_id (1 byte)][marker (16 bytes)] + * + * The suffix_data_length covers the cbor_data only (not itself, schema_id, or marker). + * + * @param data - Builder code extension data with "a" and/or "s" fields + * @returns Hex-encoded suffix bytes (without 0x prefix) ready to append to calldata + */ +export function encodeBuilderCodeSuffix(data: BuilderCodeExtensionData): Hex { + const cborBytes = encodeCborMap(data); + const cborLength = cborBytes.length; + + // suffix_data_length is 2 bytes, big-endian + const lengthHigh = (cborLength >> 8) & 0xff; + const lengthLow = cborLength & 0xff; + + // Build the full suffix: [cbor][length 2B][schema_id 1B][marker 16B] + const suffixBytes = new Uint8Array(cborLength + 2 + 1 + 16); + let offset = 0; + + // CBOR data + suffixBytes.set(cborBytes, offset); + offset += cborLength; + + // Suffix data length (2 bytes, big-endian) + suffixBytes[offset++] = lengthHigh; + suffixBytes[offset++] = lengthLow; + + // Schema ID + suffixBytes[offset++] = SCHEMA_2_ID; + + // ERC-8021 marker (16 bytes) + const markerBytes = hexToBytes(ERC_8021_MARKER); + suffixBytes.set(markerBytes, offset); + + return `0x${bytesToHex(suffixBytes)}`; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/typescript/packages/extensions/src/builder-code/facilitator.ts b/typescript/packages/extensions/src/builder-code/facilitator.ts new file mode 100644 index 0000000000..79b8300b7f --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/facilitator.ts @@ -0,0 +1,75 @@ +/** + * Facilitator-side extension for the Builder Code Extension. + * + * At settlement time, the facilitator: + * 1. Reads builder code data from the payment payload extensions + * 2. Adds its own builder code as the "w" (wallet) field + * 3. Encodes the combined data as an ERC-8021 Schema 2 CBOR suffix + * 4. The suffix is appended to the settlement transaction calldata + */ + +import type { FacilitatorExtension } from "@x402/core/types"; +import type { Hex } from "viem"; +import { encodeBuilderCodeSuffix } from "./cbor"; +import { + BUILDER_CODE, + BUILDER_CODE_PATTERN, + type BuilderCodeExtensionData, + type BuilderCodeFacilitatorConfig, +} from "./types"; + +/** + * Facilitator extension that manages builder code attribution at settlement time. + * + * Register this with the x402Facilitator to enable builder code support. + * The extension reads builder code data from payment payloads and provides + * the encoded ERC-8021 suffix for the settlement mechanism to append. + * + * @example + * ```typescript + * import { BuilderCodeFacilitatorExtension } from '@x402/extensions/builder-code'; + * + * const facilitator = new x402Facilitator(); + * facilitator.registerExtension(new BuilderCodeFacilitatorExtension({ + * builderCode: "bc_my_facilitator", + * })); + * ``` + */ +export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { + readonly key = BUILDER_CODE; + private readonly config: BuilderCodeFacilitatorConfig; + + constructor(config: BuilderCodeFacilitatorConfig) { + if (!BUILDER_CODE_PATTERN.test(config.builderCode)) { + throw new Error( + `Invalid builder code: "${config.builderCode}". ` + + `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, + ); + } + this.config = config; + } + + /** + * Builds the ERC-8021 Schema 2 calldata suffix from payment payload extensions. + * + * Reads "a" (app/service code) and "s" (related service codes) from the + * payment payload, adds the facilitator's own code as "w" (wallet), and + * encodes as Schema 2 CBOR. + * + * @param payloadExtensions - The extensions from the payment payload + * @returns Hex-encoded suffix to append to settlement calldata, or undefined if no builder codes + */ + buildCalldataSuffix(payloadExtensions?: Record): Hex | undefined { + const extData = payloadExtensions?.[BUILDER_CODE] as + | BuilderCodeExtensionData + | undefined; + + const suffixData: BuilderCodeExtensionData = { + a: extData?.a, + w: this.config.builderCode, + s: extData?.s ? [...extData.s] : undefined, + }; + + return encodeBuilderCodeSuffix(suffixData); + } +} diff --git a/typescript/packages/extensions/src/builder-code/index.ts b/typescript/packages/extensions/src/builder-code/index.ts new file mode 100644 index 0000000000..b10f0ecea6 --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/index.ts @@ -0,0 +1,67 @@ +/** + * Builder Code Extension for x402 (ERC-8021) + * + * Enables attribution tracking for x402 payments by appending ERC-8021 + * Schema 2 builder codes to settlement transaction calldata. + * + * Two parties attach their builder code: + * - Service (server): Declares as "a" (app) in 402 response via declareBuilderCodeExtension() + * - Facilitator: Adds as "w" (wallet) at settlement via BuilderCodeFacilitatorExtension + * + * The service can optionally include related on-chain services in the "s" array + * (e.g., Morpho, Aerodrome) to attribute protocols it depends on. + * + * ## Usage + * + * ### For Services (Resource Servers) + * + * ```typescript + * import { declareBuilderCodeExtension, BUILDER_CODE } from '@x402/extensions/builder-code'; + * + * // In paywall config extensions + * extensions: { + * [BUILDER_CODE]: declareBuilderCodeExtension("bc_my_service"), + * } + * + * // With related on-chain services + * extensions: { + * [BUILDER_CODE]: declareBuilderCodeExtension("bc_my_service", ["bc_morpho", "bc_aerodrome"]), + * } + * ``` + * + * ### For Facilitators + * + * ```typescript + * import { BuilderCodeFacilitatorExtension } from '@x402/extensions/builder-code'; + * + * const facilitator = new x402Facilitator(); + * facilitator.registerExtension(new BuilderCodeFacilitatorExtension({ + * builderCode: "bc_my_facilitator", + * })); + * ``` + */ + +// Types +export type { + BuilderCodeExtensionData, + BuilderCodeFacilitatorConfig, +} from "./types"; + +export { + BUILDER_CODE, + BUILDER_CODE_PATTERN, + ERC_8021_MARKER, + SCHEMA_2_ID, +} from "./types"; + +// CBOR encoding +export { encodeBuilderCodeSuffix } from "./cbor"; + +// Resource Server +export { + declareBuilderCodeExtension, + builderCodeResourceServerExtension, +} from "./resourceServer"; + +// Facilitator +export { BuilderCodeFacilitatorExtension } from "./facilitator"; diff --git a/typescript/packages/extensions/src/builder-code/resourceServer.ts b/typescript/packages/extensions/src/builder-code/resourceServer.ts new file mode 100644 index 0000000000..891ff97f43 --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/resourceServer.ts @@ -0,0 +1,85 @@ +/** + * Resource Server utilities for the Builder Code Extension. + * + * Services use this to declare their builder code in the 402 response. + * The service's code goes in the "a" (app) field since the service is + * the application exposing the x402 endpoint. Optionally, the service + * can include related on-chain services in the "s" array. + */ + +import type { ResourceServerExtension } from "@x402/core/types"; +import { + BUILDER_CODE, + BUILDER_CODE_PATTERN, + type BuilderCodeExtensionData, +} from "./types"; + +/** + * Declares the builder-code extension for inclusion in PaymentRequired.extensions. + * + * The service's builder code is placed in the "a" (app) field — the service + * is the application that exposed the x402 endpoint. Related on-chain services + * the app depends on (e.g., Morpho, Aerodrome) can be listed in the "s" array. + * + * @param appCode - The service's builder code (e.g., "bc_weather_svc") + * @param serviceCodes - Optional array of related service builder codes + * @returns A BuilderCodeExtensionData object for PaymentRequired.extensions + * + * @example + * ```typescript + * import { declareBuilderCodeExtension, BUILDER_CODE } from '@x402/extensions/builder-code'; + * + * // Simple: just the service's own code + * extensions: { + * [BUILDER_CODE]: declareBuilderCodeExtension("bc_weather_svc"), + * } + * + * // With related services + * extensions: { + * [BUILDER_CODE]: declareBuilderCodeExtension("bc_lending_app", ["bc_morpho", "bc_aerodrome"]), + * } + * ``` + */ +export function declareBuilderCodeExtension( + appCode: string, + serviceCodes?: string[], +): BuilderCodeExtensionData { + if (!BUILDER_CODE_PATTERN.test(appCode)) { + throw new Error( + `Invalid builder code: "${appCode}". ` + + `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, + ); + } + + if (serviceCodes) { + for (const code of serviceCodes) { + if (!BUILDER_CODE_PATTERN.test(code)) { + throw new Error( + `Invalid service builder code: "${code}". ` + + `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, + ); + } + } + } + + const data: BuilderCodeExtensionData = { + a: appCode, + }; + + if (serviceCodes && serviceCodes.length > 0) { + data.s = serviceCodes; + } + + return data; +} + +/** + * ResourceServerExtension implementation for builder-code. + * + * Register this with the resource server to advertise builder code support. + * The actual builder code value is set via declareBuilderCodeExtension() + * in the paywall configuration. + */ +export const builderCodeResourceServerExtension: ResourceServerExtension = { + key: BUILDER_CODE, +}; diff --git a/typescript/packages/extensions/src/builder-code/types.ts b/typescript/packages/extensions/src/builder-code/types.ts new file mode 100644 index 0000000000..457c315e0f --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/types.ts @@ -0,0 +1,68 @@ +/** + * Type definitions for the Builder Code Extension (ERC-8021) + * + * Enables attribution tracking for x402 payments by appending + * ERC-8021 Schema 2 builder codes to settlement transaction calldata. + */ + +/** + * Extension identifier constant + */ +export const BUILDER_CODE = "builder-code"; + +/** + * ERC-8021 marker bytes (16 bytes) appended at the end of every suffix + */ +export const ERC_8021_MARKER = "80218021802180218021802180218021"; + +/** + * Schema 2 identifier byte + */ +export const SCHEMA_2_ID = 0x02; + +/** + * Pattern for valid builder codes (lowercase alphanumeric + underscore, 1-32 chars) + */ +export const BUILDER_CODE_PATTERN = /^[a-z0-9_]{1,32}$/; + +/** + * Builder code extension data as it appears in PaymentRequired/PaymentPayload extensions. + * + * Maps to ERC-8021 Schema 2 fields: + * - a: app code (the x402 service that exposed the endpoint) + * - w: wallet code (the facilitator that settled the payment on-chain) + * - s: service codes array (related on-chain services the app depends on) + */ +export interface BuilderCodeExtensionData { + /** + * App builder code — the x402 service that exposed the paid endpoint. + * Maps to the "a" field in ERC-8021 Schema 2. + * Set by the service in the 402 response. + */ + a?: string; + + /** + * Wallet builder code — the facilitator that settled the payment on-chain. + * Maps to the "w" field in ERC-8021 Schema 2. + * Set by the facilitator at settlement time. + */ + w?: string; + + /** + * Service builder codes — related on-chain services the app depends on. + * Maps to the "s" field in ERC-8021 Schema 2. + * Optionally set by the service to attribute protocols it interacts with + * (e.g., Morpho for lending, Aerodrome for swaps). + */ + s?: string[]; +} + +/** + * Configuration for the builder code facilitator extension. + */ +export interface BuilderCodeFacilitatorConfig { + /** + * The facilitator's own builder code, set as the "w" field at settlement. + */ + builderCode: string; +} diff --git a/typescript/packages/extensions/src/index.ts b/typescript/packages/extensions/src/index.ts index a8e18bfd60..1663543ff8 100644 --- a/typescript/packages/extensions/src/index.ts +++ b/typescript/packages/extensions/src/index.ts @@ -20,3 +20,6 @@ export * from "./eip2612-gas-sponsoring"; // ERC-20 Approval Gas Sponsoring extension export * from "./erc20-approval-gas-sponsoring"; + +// Builder Code extension (ERC-8021) +export * from "./builder-code"; diff --git a/typescript/packages/extensions/tsup.config.ts b/typescript/packages/extensions/tsup.config.ts index bd85a73925..fe76c754d1 100644 --- a/typescript/packages/extensions/tsup.config.ts +++ b/typescript/packages/extensions/tsup.config.ts @@ -7,6 +7,7 @@ const baseConfig = { "sign-in-with-x/index": "src/sign-in-with-x/index.ts", "offer-receipt/index": "src/offer-receipt/index.ts", "payment-identifier/index": "src/payment-identifier/index.ts", + "builder-code/index": "src/builder-code/index.ts", }, dts: { resolve: true, diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts index 2d8f2c4a69..e55fe09db2 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts @@ -191,9 +191,15 @@ export async function diagnoseEip3009SimulationFailure( /** * Executes transferWithAuthorization onchain. * + * When a calldataSuffix is provided (e.g., ERC-8021 builder code attribution), + * the function manually encodes the calldata, appends the suffix, and uses + * sendTransaction instead of writeContract. The EVM ignores trailing calldata + * bytes, so the transfer executes normally while indexers can read the suffix. + * * @param signer - EVM signer for contract writes * @param erc20Address - ERC-20 token contract address * @param payload - EIP-3009 transfer authorization payload + * @param calldataSuffix - Optional hex bytes to append after the ABI-encoded calldata * * @returns Transaction hash */ @@ -201,6 +207,7 @@ export async function executeTransferWithAuthorization( signer: FacilitatorEvmSigner, erc20Address: `0x${string}`, payload: ExactEIP3009Payload, + calldataSuffix?: Hex, ): Promise { const { signature } = parseErc6492Signature(payload.signature!); const signatureLength = signature.startsWith("0x") ? signature.length - 2 : signature.length; @@ -216,10 +223,36 @@ export async function executeTransferWithAuthorization( auth.nonce, ] as const; - if (isECDSA) { - const parsedSig = parseSignature(signature); + // If no suffix, use the standard writeContract path (unchanged behavior) + if (!calldataSuffix) { + if (isECDSA) { + const parsedSig = parseSignature(signature); + return signer.writeContract({ + address: erc20Address, + abi: eip3009ABI, + functionName: "transferWithAuthorization", + args: [ + ...baseArgs, + (parsedSig.v as number | undefined) || parsedSig.yParity, + parsedSig.r, + parsedSig.s, + ], + }); + } + return signer.writeContract({ address: erc20Address, + abi: eip3009ABI, + functionName: "transferWithAuthorization", + args: [...baseArgs, signature], + }); + } + + // With suffix: encode calldata manually, append suffix, use sendTransaction + let calldata: Hex; + if (isECDSA) { + const parsedSig = parseSignature(signature); + calldata = encodeFunctionData({ abi: eip3009ABI, functionName: "transferWithAuthorization", args: [ @@ -229,12 +262,20 @@ export async function executeTransferWithAuthorization( parsedSig.s, ], }); + } else { + calldata = encodeFunctionData({ + abi: eip3009ABI, + functionName: "transferWithAuthorization", + args: [...baseArgs, signature], + }); } - return signer.writeContract({ - address: erc20Address, - abi: eip3009ABI, - functionName: "transferWithAuthorization", - args: [...baseArgs, signature], + // Append the suffix (strip 0x prefix from suffix before concatenating) + const suffixHex = calldataSuffix.startsWith("0x") ? calldataSuffix.slice(2) : calldataSuffix; + const calldataWithSuffix = `${calldata}${suffixHex}` as Hex; + + return signer.sendTransaction({ + to: erc20Address, + data: calldataWithSuffix, }); } diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts index 69056a938b..f57fc8a6f1 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts @@ -1,6 +1,7 @@ import { PaymentPayload, PaymentRequirements, + FacilitatorContext, SettleResponse, VerifyResponse, } from "@x402/core/types"; @@ -235,11 +236,16 @@ export async function verifyEIP3009( /** * Settles an EIP-3009 payment by executing transferWithAuthorization. * + * If a BuilderCodeFacilitatorExtension is registered, the facilitator will + * append an ERC-8021 Schema 2 suffix to the settlement transaction calldata + * containing builder codes from the agent, service, and facilitator. + * * @param signer - The facilitator signer for contract writes * @param payload - The payment payload to settle * @param requirements - The payment requirements * @param eip3009Payload - The EIP-3009 specific payload * @param config - Facilitator configuration + * @param context - Optional facilitator context for extension capabilities * @returns Promise resolving to settlement response */ export async function settleEIP3009( @@ -248,6 +254,7 @@ export async function settleEIP3009( requirements: PaymentRequirements, eip3009Payload: ExactEIP3009Payload, config: EIP3009FacilitatorConfig, + context?: FacilitatorContext, ): Promise { const payer = eip3009Payload.authorization.from; @@ -293,10 +300,22 @@ export async function settleEIP3009( } } + // Build ERC-8021 calldata suffix if builder code extension is registered + let calldataSuffix: Hex | undefined; + if (context) { + const builderCodeExt = context.getExtension("builder-code"); + if (builderCodeExt && "buildCalldataSuffix" in builderCodeExt) { + calldataSuffix = (builderCodeExt as { buildCalldataSuffix: (ext?: Record) => Hex | undefined }).buildCalldataSuffix( + payload.extensions as Record | undefined, + ); + } + } + const tx = await executeTransferWithAuthorization( signer, getAddress(requirements.asset), eip3009Payload, + calldataSuffix, ); // Wait for transaction confirmation diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts index d5bcd5afc6..8904333beb 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts @@ -121,6 +121,6 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { } const eip3009Payload: ExactEIP3009Payload = rawPayload; - return settleEIP3009(this.signer, payload, requirements, eip3009Payload, this.config); + return settleEIP3009(this.signer, payload, requirements, eip3009Payload, this.config, context); } }