From 03800601d0016ac72d004a8a5ef622e66968ffd5 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:53:17 +1100 Subject: [PATCH] feat: add erc8021 utils --- .changeset/ten-windows-confess.md | 5 + src/erc8021/Attribution.ts | 267 ++++++++++++++++++++ src/erc8021/_test/Attribution.test.ts | 347 ++++++++++++++++++++++++++ src/erc8021/_test/index.test.ts | 10 + src/erc8021/index.ts | 38 +++ src/index.docs.ts | 1 + tsconfig.json | 1 + 7 files changed, 669 insertions(+) create mode 100644 .changeset/ten-windows-confess.md create mode 100644 src/erc8021/Attribution.ts create mode 100644 src/erc8021/_test/Attribution.test.ts create mode 100644 src/erc8021/_test/index.test.ts create mode 100644 src/erc8021/index.ts diff --git a/.changeset/ten-windows-confess.md b/.changeset/ten-windows-confess.md new file mode 100644 index 00000000..5124f6c4 --- /dev/null +++ b/.changeset/ten-windows-confess.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Added `ox/erc8021` entrypoint. diff --git a/src/erc8021/Attribution.ts b/src/erc8021/Attribution.ts new file mode 100644 index 00000000..26a393ac --- /dev/null +++ b/src/erc8021/Attribution.ts @@ -0,0 +1,267 @@ +import type * as Address from '../core/Address.js' +import type * as Errors from '../core/Errors.js' +import * as Hex from '../core/Hex.js' +import type { OneOf } from '../core/internal/types.js' + +/** + * ERC-8021 Transaction Attribution. + * + * Represents attribution metadata that can be appended to transaction calldata + * to track entities involved in facilitating a transaction. + */ +export type Attribution = OneOf + +/** + * Schema 0: Canonical Registry Attribution. + * + * Uses the canonical attribution code registry for resolving entity identities. + */ +export type AttributionSchemaId0 = { + /** Attribution codes identifying entities involved in the transaction. */ + codes: readonly string[] + /** Schema identifier (0 for canonical registry). */ + id?: 0 | undefined +} + +/** + * Schema 1: Custom Registry Attribution. + * + * Uses a custom registry contract for resolving attribution codes. + */ +export type AttributionSchemaId1 = { + /** Attribution codes identifying entities involved in the transaction. */ + codes: readonly string[] + /** Address of the custom code registry contract. */ + codeRegistryAddress: Address.Address + /** Schema identifier (1 for custom registry). */ + id?: 1 | undefined +} + +/** + * Attribution schema identifier. + * + * - `0`: Canonical registry + * - `1`: Custom registry + */ +export type SchemaId = NonNullable< + AttributionSchemaId0['id'] | AttributionSchemaId1['id'] +> + +/** + * ERC-8021 suffix identifier. + */ +export const ercSuffix = '0x80218021802180218021802180218021' as const + +/** + * Size of the ERC-8021 suffix (16 bytes). + */ +export const ercSuffixSize = /*#__PURE__*/ Hex.size(ercSuffix) + +/** + * Determines the schema ID for an {@link ox#Attribution.Attribution}. + * + * @example + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const schemaId = Attribution.getSchemaId({ + * codes: ['baseapp'] + * }) + * // @log: 0 + * + * const schemaId2 = Attribution.getSchemaId({ + * codes: ['baseapp'], + * codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + * }) + * // @log: 1 + * ``` + * + * @param attribution - The attribution object. + * @returns The schema ID (0 for canonical registry, 1 for custom registry). + */ +export function getSchemaId(attribution: Attribution): SchemaId { + if ('codeRegistryAddress' in attribution) return 1 + return 0 +} + +export declare namespace getSchemaId { + type ErrorType = Errors.GlobalErrorType +} + +/** + * Converts an {@link ox#Attribution.Attribution} to a data suffix that can be appended to transaction calldata. + * + * @example + * ### Schema 0 (Canonical Registry) + * + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const suffix = Attribution.toDataSuffix({ + * codes: ['baseapp', 'morpho'] + * }) + * // @log: '0x626173656170702c6d6f7270686f0e0080218021802180218021802180218021' + * ``` + * + * @example + * ### Schema 1 (Custom Registry) + * + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const suffix = Attribution.toDataSuffix({ + * codes: ['baseapp'], + * codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + * }) + * ``` + * + * @param attribution - The attribution to convert. + * @returns The data suffix as a {@link ox#Hex.Hex} value. + */ +export function toDataSuffix(attribution: Attribution): Hex.Hex { + // Encode the codes as ASCII strings separated by commas + const codesHex = Hex.fromString(attribution.codes.join(',')) + + // Get the byte length of the encoded codes + const codesLength = Hex.size(codesHex) + + // Encode the codes length as 1 byte + const codesLengthHex = Hex.fromNumber(codesLength, { size: 1 }) + + // Determine schema ID + const schemaId = getSchemaId(attribution) + const schemaIdHex = Hex.fromNumber(schemaId, { size: 1 }) + + // Build the suffix based on schema + if (schemaId === 1) + // Schema 1: codeRegistryAddress ∥ codes ∥ codesLength ∥ schemaId ∥ ercSuffix + return Hex.concat( + attribution.codeRegistryAddress!.toLowerCase() as Address.Address, + codesHex, + codesLengthHex, + schemaIdHex, + ercSuffix, + ) + + // Schema 0: codes ∥ codesLength ∥ schemaId ∥ ercSuffix + return Hex.concat(codesHex, codesLengthHex, schemaIdHex, ercSuffix) +} + +export declare namespace toDataSuffix { + type ErrorType = + | getSchemaId.ErrorType + | Hex.concat.ErrorType + | Hex.fromString.ErrorType + | Hex.fromNumber.ErrorType + | Hex.size.ErrorType + | Errors.GlobalErrorType +} + +/** + * Extracts an {@link ox#Attribution.Attribution} from transaction calldata. + * + * @example + * ### Schema 0 (Canonical Registry) + * + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const attribution = Attribution.fromData( + * '0xdddddddd62617365617070070080218021802180218021802180218021' + * ) + * // @log: { codes: ['baseapp'], id: 0 } + * ``` + * + * @example + * ### Schema 1 (Custom Registry) + * + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const attribution = Attribution.fromData( + * '0xdddddddd626173656170702c6d6f7270686f0ecccccccccccccccccccccccccccccccccccccccc0180218021802180218021802180218021' + * ) + * // @log: { + * // codes: ['baseapp', 'morpho'], + * // codeRegistryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + * // id: 1 + * // } + * ``` + * + * @param data - The transaction calldata containing the attribution suffix. + * @returns The extracted attribution, or undefined if no valid attribution is found. + */ +export function fromData(data: Hex.Hex): Attribution | undefined { + // Check minimum length: ERC suffix (16 bytes) + schema ID (1 byte) + length (1 byte) = 18 bytes + const minSize = ercSuffixSize + 1 + 1 + if (Hex.size(data) < minSize) return undefined + + // Verify ERC suffix is present at the end + const suffix = Hex.slice(data, -ercSuffixSize) + if (suffix !== ercSuffix) return undefined + + // Extract schema ID (1 byte before the ERC suffix) + const schemaIdHex = Hex.slice(data, -ercSuffixSize - 1, -ercSuffixSize) + const schemaId = Hex.toNumber(schemaIdHex) + + // Schema 0: Canonical registry + if (schemaId === 0) { + // Extract codes length (1 byte before schema ID) + const codesLengthHex = Hex.slice( + data, + -ercSuffixSize - 2, + -ercSuffixSize - 1, + ) + const codesLength = Hex.toNumber(codesLengthHex) + + // Extract codes + const codesStart = -ercSuffixSize - 2 - codesLength + const codesEnd = -ercSuffixSize - 2 + const codesHex = Hex.slice(data, codesStart, codesEnd) + const codesString = Hex.toString(codesHex) + const codes = codesString.length > 0 ? codesString.split(',') : [] + + return { codes, id: 0 } + } + + // Schema 1: Custom registry + // Format: codeRegistryAddress (20 bytes) ∥ codes ∥ codesLength (1 byte) ∥ schemaId (1 byte) ∥ ercSuffix + if (schemaId === 1) { + // Extract codes length (1 byte before schema ID) + const codesLengthHex = Hex.slice( + data, + -ercSuffixSize - 2, + -ercSuffixSize - 1, + ) + const codesLength = Hex.toNumber(codesLengthHex) + + // Extract codes + const codesStart = -ercSuffixSize - 2 - codesLength + const codesEnd = -ercSuffixSize - 2 + const codesHex = Hex.slice(data, codesStart, codesEnd) + const codesString = Hex.toString(codesHex) + const codes = codesString.length > 0 ? codesString.split(',') : [] + + // Extract registry address (20 bytes before codes) + const registryStart = codesStart - 20 + const codeRegistryAddress = Hex.slice(data, registryStart, codesStart) + + return { + codes, + codeRegistryAddress, + id: 1, + } + } + + // Unknown schema ID + return undefined +} + +export declare namespace fromData { + type ErrorType = + | Hex.slice.ErrorType + | Hex.toNumber.ErrorType + | Hex.toString.ErrorType + | Hex.size.ErrorType + | Errors.GlobalErrorType +} diff --git a/src/erc8021/_test/Attribution.test.ts b/src/erc8021/_test/Attribution.test.ts new file mode 100644 index 00000000..99b0e4a4 --- /dev/null +++ b/src/erc8021/_test/Attribution.test.ts @@ -0,0 +1,347 @@ +import { Attribution } from 'ox/erc8021' +import { describe, expect, test } from 'vitest' + +describe('getSchemaId', () => { + test('returns 0 for canonical registry (no codeRegistryAddress)', () => { + const schemaId = Attribution.getSchemaId({ + codes: ['baseapp'], + }) + + expect(schemaId).toBe(0) + }) + + test('returns 0 for canonical registry (explicit id: 0)', () => { + const schemaId = Attribution.getSchemaId({ + codes: ['baseapp'], + id: 0, + }) + + expect(schemaId).toBe(0) + }) + + test('returns 1 for custom registry', () => { + const schemaId = Attribution.getSchemaId({ + codes: ['baseapp'], + codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }) + + expect(schemaId).toBe(1) + }) + + test('returns 1 for custom registry (explicit id: 1)', () => { + const schemaId = Attribution.getSchemaId({ + codes: ['baseapp'], + codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + id: 1, + }) + + expect(schemaId).toBe(1) + }) +}) + +describe('toDataSuffix', () => { + describe('schema 0 (canonical registry)', () => { + test('single code', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp'], + }) + + // Expected: 'baseapp' (7 bytes) + length (1 byte: 0x07) + schema id (1 byte: 0x00) + erc suffix (16 bytes) + // 'baseapp' = 0x626173656170702c + expect(suffix).toBe( + '0x62617365617070070080218021802180218021802180218021', + ) + }) + + test('multiple codes', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp', 'morpho'], + }) + + // Expected: 'baseapp,morpho' (14 bytes) + length (1 byte: 0x0e) + schema id (1 byte: 0x00) + erc suffix (16 bytes) + // 'baseapp,morpho' = 0x626173656170702c6d6f7270686f (14 bytes) + expect(suffix).toBe( + '0x626173656170702c6d6f7270686f0e0080218021802180218021802180218021', + ) + }) + + test('explicit id', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['test'], + id: 0, + }) + + // Expected: 'test' (4 bytes) + length (1 byte: 0x04) + schema id (1 byte: 0x00) + erc suffix (16 bytes) + expect(suffix).toBe('0x74657374040080218021802180218021802180218021') + }) + + test('three codes', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['base', 'uniswap', 'cowswap'], + }) + + // 'base,uniswap,cowswap' = 20 bytes + expect(suffix).toMatchInlineSnapshot( + `"0x626173652c756e69737761702c636f7773776170140080218021802180218021802180218021"`, + ) + }) + + test('single character code', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['a'], + }) + + // 'a' = 1 byte + expect(suffix).toMatchInlineSnapshot( + `"0x61010080218021802180218021802180218021"`, + ) + }) + + test('codes with numbers', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['app123', 'test456'], + }) + + // 'app123,test456' = 14 bytes + expect(suffix).toMatchInlineSnapshot( + `"0x6170703132332c746573743435360e0080218021802180218021802180218021"`, + ) + }) + + test('codes with special characters', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['app-name', 'test_code'], + }) + + // 'app-name,test_code' = 18 bytes + expect(suffix).toMatchInlineSnapshot( + `"0x6170702d6e616d652c746573745f636f6465120080218021802180218021802180218021"`, + ) + }) + + test('empty codes array', () => { + const suffix = Attribution.toDataSuffix({ + codes: [], + }) + + // '' = 0 bytes + expect(suffix).toMatchInlineSnapshot( + `"0x000080218021802180218021802180218021"`, + ) + }) + + test('codes with mixed case preserved', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['BaseApp', 'MorphoProtocol'], + }) + + // 'BaseApp,MorphoProtocol' = 22 bytes + expect(suffix).toMatchInlineSnapshot( + `"0x426173654170702c4d6f7270686f50726f746f636f6c160080218021802180218021802180218021"`, + ) + }) + + test('spec example: single entity', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp'], + id: 0, + }) + + // Expected segment after txData (0xdddddddd) + // 'baseapp' (7 bytes) + length (0x07) + schema ID (0x00) + ERC suffix + expect(suffix).toBe( + '0x62617365617070070080218021802180218021802180218021', + ) + }) + }) + + describe('schema 1 (custom registry)', () => { + test('single code', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp'], + codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }) + + // Expected: address (20 bytes) + 'baseapp' (7 bytes) + length (1 byte: 0x07) + schema id (1 byte: 0x01) + erc suffix (16 bytes) + expect(suffix).toBe( + '0xd8da6bf26964af9d7eed9e03e53415d37aa9604562617365617070070180218021802180218021802180218021', + ) + }) + + test('multiple codes', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp', 'morpho'], + codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + id: 1, + }) + + // Expected: address (20 bytes) + 'baseapp,morpho' (14 bytes) + length (1 byte: 0x0e) + schema id (1 byte: 0x01) + erc suffix (16 bytes) + expect(suffix).toBe( + '0xd8da6bf26964af9d7eed9e03e53415d37aa96045626173656170702c6d6f7270686f0e0180218021802180218021802180218021', + ) + }) + + test('single character code', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['x'], + codeRegistryAddress: '0x1234567890123456789012345678901234567890', + }) + + // address (20 bytes) + 'x' (1 byte) + codesLength (1 byte: 0x01) + schemaId (1 byte: 0x01) + ercSuffix + expect(suffix).toBe( + '0x123456789012345678901234567890123456789078010180218021802180218021802180218021', + ) + }) + + test('long codes', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['verylongapplicationname', 'anotherlongcode'], + codeRegistryAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }) + + // address (20 bytes) + 'verylongapplicationname,anotherlongcode' (39 bytes) + length (0x27) + schemaId (0x01) + ercSuffix + expect(suffix).toBe( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd766572796c6f6e676170706c69636174696f6e6e616d652c616e6f746865726c6f6e67636f6465270180218021802180218021802180218021', + ) + }) + + test('spec example: multiple entities', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp', 'morpho'], + codeRegistryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + id: 1, + }) + + // Expected segment after txData (0xdddddddd) + // registry address (20 bytes) + 'baseapp,morpho' (14 bytes) + length (0x0e) + schema ID (0x01) + ERC suffix + expect(suffix).toBe( + '0xcccccccccccccccccccccccccccccccccccccccc626173656170702c6d6f7270686f0e0180218021802180218021802180218021', + ) + }) + }) +}) + +describe('fromData', () => { + describe('schema 0 (canonical registry)', () => { + test('single entity', () => { + // Input: transaction data + 'baseapp' (7 bytes) + length (0x07) + schema ID (0x00) + ERC suffix + const input = + '0xdddddddd62617365617070070080218021802180218021802180218021' + + const result = Attribution.fromData(input) + + expect(result).toEqual({ + codes: ['baseapp'], + id: 0, + }) + }) + + test('empty codes', () => { + // Input: transaction data + empty codes (0 bytes) + length (0x00) + schema ID (0x00) + ERC suffix + const input = '0xdddddddd000080218021802180218021802180218021' + + const result = Attribution.fromData(input) + + // Empty string splits to [''] not [] + expect(result).toEqual({ + codes: [], + id: 0, + }) + }) + + test('roundtrip', () => { + const original = { + codes: ['baseapp', 'morpho', 'uniswap'], + } + + const suffix = Attribution.toDataSuffix(original) + const fullData = `0xabcdef${suffix.slice(2)}` as const // Add some transaction data + + const parsed = Attribution.fromData(fullData) + + expect(parsed).toEqual({ + codes: original.codes, + id: 0, + }) + }) + }) + + describe('schema 1 (custom registry)', () => { + test('multiple entities', () => { + // Input: transaction data + registry address (20 bytes) + 'baseapp,morpho' (14 bytes) + length (0x0e) + schema ID (0x01) + ERC suffix + const input = + '0xddddddddcccccccccccccccccccccccccccccccccccccccc626173656170702c6d6f7270686f0e0180218021802180218021802180218021' + + const result = Attribution.fromData(input) + + expect(result).toEqual({ + codes: ['baseapp', 'morpho'], + codeRegistryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + id: 1, + }) + }) + + test('single code', () => { + // Input: transaction data + registry address (20 bytes) + 'x' (1 byte) + length (0x01) + schema ID (0x01) + ERC suffix + const input = + '0xdddddddd123456789012345678901234567890123456789078010180218021802180218021802180218021' + + const result = Attribution.fromData(input) + + expect(result).toEqual({ + codes: ['x'], + codeRegistryAddress: '0x1234567890123456789012345678901234567890', + id: 1, + }) + }) + + test('roundtrip', () => { + const original = { + codes: ['baseapp', 'morpho'], + codeRegistryAddress: + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as const, + } + + const suffix = Attribution.toDataSuffix(original) + const fullData = `0xabcdef${suffix.slice(2)}` as const // Add some transaction data + + const parsed = Attribution.fromData(fullData) + + expect(parsed).toEqual({ + codes: original.codes, + codeRegistryAddress: original.codeRegistryAddress.toLowerCase(), + id: 1, + }) + }) + }) + + describe('error cases', () => { + test('invalid schemaId', () => { + // Input: transaction data + length (0xff) + unknown schema ID (0xff) + ERC suffix + const input = '0xddddddddff80218021802180218021802180218021' + + const result = Attribution.fromData(input) + + // Parsing stops, unknown schemaId returns undefined + expect(result).toBeUndefined() + }) + + test('missing ERC suffix', () => { + // Input without valid ERC suffix + const input = + '0xdddddddd62617365617070070000000000000000000000000000000000000000' + + const result = Attribution.fromData(input) + + expect(result).toBeUndefined() + }) + + test('data too short', () => { + const input = '0xdddddddd' + + const result = Attribution.fromData(input) + + expect(result).toBeUndefined() + }) + }) +}) diff --git a/src/erc8021/_test/index.test.ts b/src/erc8021/_test/index.test.ts new file mode 100644 index 00000000..91fb5bc6 --- /dev/null +++ b/src/erc8021/_test/index.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'vitest' +import * as exports from '../index.js' + +test('exports', () => { + expect(Object.keys(exports)).toMatchInlineSnapshot(` + [ + "SignatureErc8010", + ] + `) +}) diff --git a/src/erc8021/index.ts b/src/erc8021/index.ts new file mode 100644 index 00000000..6f5069fc --- /dev/null +++ b/src/erc8021/index.ts @@ -0,0 +1,38 @@ +/** @entrypointCategory ERCs */ +// biome-ignore lint/complexity/noUselessEmptyExport: tsdoc +export type {} + +/** + * Utility functions for working with [ERC-8021 Transaction Attribution](https://eip.tools/eip/8021). + * + * @example + * ### Converting an Attribution to Data Suffix + * + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const dataSuffix1 = Attribution.toDataSuffix({ + * codes: ['baseapp'] + * }) + * + * const dataSuffix2 = Attribution.toDataSuffix({ + * codes: ['baseapp', 'morpho'], + * codeRegistryAddress: '0x...' + * }) + * ``` + * + * @example + * ### Extracting an Attribution from Calldata + * + * ```ts twoslash + * import { Attribution } from 'ox/erc8021' + * + * const attribution = Attribution.fromData('0x...') + * + * console.log(attribution) + * // @log: { codes: ['baseapp', 'morpho'], codeRegistryAddress: '0x...' } + * ``` + * + * @category ERC-8021 + */ +export * as Attribution from './Attribution.js' diff --git a/src/index.docs.ts b/src/index.docs.ts index 7b726e1d..382a0df1 100644 --- a/src/index.docs.ts +++ b/src/index.docs.ts @@ -6,3 +6,4 @@ export * from './erc4337/index.js' export * from './erc6492/index.js' export * from './erc7821/index.js' export * from './erc8010/index.js' +export * from './erc8021/index.js' diff --git a/tsconfig.json b/tsconfig.json index dece00da..692ff0d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "ox/erc6492": ["src/erc6492/index.ts"], "ox/erc7821": ["src/erc7821/index.ts"], "ox/erc8010": ["src/erc8010/index.ts"], + "ox/erc8021": ["src/erc8021/index.ts"], "ox/trusted-setups": ["src/trusted-setups/index.ts"], "ox/window": ["src/window/index.ts"] }