diff --git a/.changeset/purple-pillows-sparkle.md b/.changeset/purple-pillows-sparkle.md new file mode 100644 index 00000000..f16d1c0b --- /dev/null +++ b/.changeset/purple-pillows-sparkle.md @@ -0,0 +1,16 @@ +--- +"ox": minor +--- + +**Breaking:** Aligned to latest ERC-8021 specification. Modified `Attribution.toDataSuffix` parameters to include `codeRegistry` instead of `registryAddress`. + +```diff ts twoslash +Attribution.toDataSuffix({ + codes: ['baseapp', 'morpho'], +- registryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', ++ codeRegistry: { ++ address: '0xcccccccccccccccccccccccccccccccccccccccc` ++ chainId: 8453, ++ } +}) +``` diff --git a/src/erc8021/Attribution.ts b/src/erc8021/Attribution.ts index 0a41a5c4..22755873 100644 --- a/src/erc8021/Attribution.ts +++ b/src/erc8021/Attribution.ts @@ -31,12 +31,19 @@ export type AttributionSchemaId0 = { export type AttributionSchemaId1 = { /** Attribution codes identifying entities involved in the transaction. */ codes: readonly string[] - /** Address of the custom code registry contract. */ - codeRegistryAddress: Address.Address + /* The custom code registry contract. */ + codeRegistry: AttributionSchemaId1Registry /** Schema identifier (1 for custom registry). */ id?: 1 | undefined } +export type AttributionSchemaId1Registry = { + /** Address of the custom code registry contract. */ + address: Address.Address + /** Chain Id of the chain the custom code registry contract is deployed on. */ + chainId: number +} + /** * Attribution schema identifier. * @@ -71,7 +78,10 @@ export const ercSuffixSize = /*#__PURE__*/ Hex.size(ercSuffix) * * const schemaId2 = Attribution.getSchemaId({ * codes: ['baseapp'], - * codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + * codeRegistry: { + * address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + * chainId: 8453, + * } * }) * // @log: 1 * ``` @@ -80,7 +90,7 @@ export const ercSuffixSize = /*#__PURE__*/ Hex.size(ercSuffix) * @returns The schema ID (0 for canonical registry, 1 for custom registry). */ export function getSchemaId(attribution: Attribution): SchemaId { - if ('codeRegistryAddress' in attribution) return 1 + if ('codeRegistry' in attribution) return 1 return 0 } @@ -111,7 +121,10 @@ export declare namespace getSchemaId { * * const suffix = Attribution.toDataSuffix({ * codes: ['baseapp'], - * codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + * codeRegistry: { + * address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + * chainId: 8453, + * } * }) * ``` * @@ -133,15 +146,17 @@ export function toDataSuffix(attribution: Attribution): Hex.Hex { const schemaIdHex = Hex.fromNumber(schemaId, { size: 1 }) // Build the suffix based on schema - if (schemaId === 1) - // Schema 1: codeRegistryAddress ∥ codes ∥ codesLength ∥ schemaId ∥ ercSuffix + if (schemaId === 1) { + const schema1 = attribution as AttributionSchemaId1 + // Schema 1: codeRegistryAddress (20 bytes) ∥ chainId ∥ chainIdLength (1 byte) ∥ codes ∥ codesLength (1 byte) ∥ schemaId (1 byte) ∥ ercSuffix return Hex.concat( - attribution.codeRegistryAddress!.toLowerCase() as Address.Address, + registryToData(schema1.codeRegistry), codesHex, codesLengthHex, schemaIdHex, ercSuffix, ) + } // Schema 0: codes ∥ codesLength ∥ schemaId ∥ ercSuffix return Hex.concat(codesHex, codesLengthHex, schemaIdHex, ercSuffix) @@ -179,11 +194,14 @@ export declare namespace toDataSuffix { * import { Attribution } from 'ox/erc8021' * * const attribution = Attribution.fromData( - * '0xddddddddcccccccccccccccccccccccccccccccccccccccc626173656170702c6d6f7270686f0e0180218021802180218021802180218021' + * '0xddddddddcccccccccccccccccccccccccccccccccccccccc210502626173656170702C6D6F7270686F0E0180218021802180218021802180218021' * ) * // @log: { * // @log: codes: ['baseapp', 'morpho'], - * // @log: codeRegistryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + * // @log: registry: { + * // @log: address: '0xcccccccccccccccccccccccccccccccccccccccc` + * // @log: chainId: 8453, + * // @log: } * // @log: id: 1 * // @log: } * ``` @@ -225,7 +243,7 @@ export function fromData(data: Hex.Hex): Attribution | undefined { } // Schema 1: Custom registry - // Format: codeRegistryAddress (20 bytes) ∥ codes ∥ codesLength (1 byte) ∥ schemaId (1 byte) ∥ ercSuffix + // Format: codeRegistryAddress (20 bytes) ∥ chainId ∥ chainIdLength (1 byte) ∥ codes ∥ codesLength (1 byte) ∥ schemaId (1 byte) ∥ ercSuffix if (schemaId === 1) { // Extract codes length (1 byte before schema ID) const codesLengthHex = Hex.slice( @@ -242,13 +260,13 @@ export function fromData(data: Hex.Hex): Attribution | undefined { 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) + // Extract registry by reading backwards from just before codes + const codeRegistry = registryFromData(Hex.slice(data, 0, codesStart)) + if (codeRegistry === undefined) return undefined return { codes, - codeRegistryAddress, + codeRegistry, id: 1, } } @@ -257,6 +275,50 @@ export function fromData(data: Hex.Hex): Attribution | undefined { return undefined } +function registryFromData( + data: Hex.Hex, +): AttributionSchemaId1Registry | undefined { + // Expect at least: address (20 bytes) + chainIdLen (1 byte) + const minRegistrySize = 20 + 1 + if (Hex.size(data) < minRegistrySize) return undefined + + // Read chainId length from the last byte of the registry segment + const chainIdLenHex = Hex.slice(data, -1) + const chainIdLen = Hex.toNumber(chainIdLenHex) + + if (chainIdLen === 0) return undefined + + // Validate we have enough bytes to cover address + chainId + chainIdLen + const totalRegistrySize = 20 + chainIdLen + 1 + if (Hex.size(data) < totalRegistrySize) return undefined + + // Address is located immediately before chainId and chainIdLen (read from back) + const addressStart = -(chainIdLen + 1 + 20) + const addressEnd = -(chainIdLen + 1) + const addressHex = Hex.slice(data, addressStart, addressEnd) + // Chain ID occupies the bytes preceding the final length byte (read from back) + const chainIdHex = Hex.slice(data, -(chainIdLen + 1), -1) + + const codeRegistry: AttributionSchemaId1Registry = { + address: addressHex as Address.Address, + chainId: Hex.toNumber(chainIdHex), + } + + return codeRegistry +} + +function registryToData(registry: AttributionSchemaId1Registry): Hex.Hex { + const chainIdAsHex = Hex.fromNumber(registry.chainId) + const chainIdLen = Hex.size(chainIdAsHex) + // Need to padleft because the output of size may not be a full byte (1 -> 0x1 vs 0x01) + const paddedChainId = Hex.padLeft(chainIdAsHex, chainIdLen) + return Hex.concat( + registry.address as Hex.Hex, + paddedChainId, + Hex.fromNumber(chainIdLen, { size: 1 }), + ) +} + export declare namespace fromData { type ErrorType = | Hex.slice.ErrorType diff --git a/src/erc8021/_test/Attribution.test.ts b/src/erc8021/_test/Attribution.test.ts index 99b0e4a4..2310eacf 100644 --- a/src/erc8021/_test/Attribution.test.ts +++ b/src/erc8021/_test/Attribution.test.ts @@ -2,7 +2,7 @@ import { Attribution } from 'ox/erc8021' import { describe, expect, test } from 'vitest' describe('getSchemaId', () => { - test('returns 0 for canonical registry (no codeRegistryAddress)', () => { + test('returns 0 for canonical registry (no codeRegistry)', () => { const schemaId = Attribution.getSchemaId({ codes: ['baseapp'], }) @@ -22,7 +22,10 @@ describe('getSchemaId', () => { test('returns 1 for custom registry', () => { const schemaId = Attribution.getSchemaId({ codes: ['baseapp'], - codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + codeRegistry: { + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + chainId: 1, + }, }) expect(schemaId).toBe(1) @@ -31,7 +34,10 @@ describe('getSchemaId', () => { test('returns 1 for custom registry (explicit id: 1)', () => { const schemaId = Attribution.getSchemaId({ codes: ['baseapp'], - codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + codeRegistry: { + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + chainId: 1, + }, id: 1, }) @@ -157,66 +163,118 @@ describe('toDataSuffix', () => { describe('schema 1 (custom registry)', () => { test('single code', () => { - const suffix = Attribution.toDataSuffix({ + const input = { 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', + codeRegistry: { + address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' as const, + chainId: 1, + }, + id: 1 as const, + } + const suffix = Attribution.toDataSuffix(input) + const parsed = Attribution.fromData( + `0xdddddddd${suffix.slice(2)}` as const, ) + expect(parsed).toEqual({ + codes: ['baseapp'], + codeRegistry: { + address: input.codeRegistry.address.toLowerCase(), + chainId: 1, + }, + id: 1, + }) }) test('multiple codes', () => { - const suffix = Attribution.toDataSuffix({ + const input = { + codes: ['baseapp', 'morpho'], + codeRegistry: { + address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' as const, + chainId: 8453, + }, + id: 1 as const, + } + const suffix = Attribution.toDataSuffix(input) + const parsed = Attribution.fromData( + `0xeeeeeeee${suffix.slice(2)}` as const, + ) + expect(parsed).toEqual({ codes: ['baseapp', 'morpho'], - codeRegistryAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + codeRegistry: { + address: input.codeRegistry.address.toLowerCase(), + chainId: 8453, + }, 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({ + const input = { codes: ['x'], - codeRegistryAddress: '0x1234567890123456789012345678901234567890', - }) - - // address (20 bytes) + 'x' (1 byte) + codesLength (1 byte: 0x01) + schemaId (1 byte: 0x01) + ercSuffix - expect(suffix).toBe( - '0x123456789012345678901234567890123456789078010180218021802180218021802180218021', + codeRegistry: { + address: '0x1234567890123456789012345678901234567890' as const, + chainId: 10, + }, + id: 1 as const, + } + const suffix = Attribution.toDataSuffix(input) + const parsed = Attribution.fromData( + `0xcccccccc${suffix.slice(2)}` as const, ) + expect(parsed).toEqual({ + codes: ['x'], + codeRegistry: { + address: input.codeRegistry.address.toLowerCase(), + chainId: 10, + }, + id: 1, + }) }) test('long codes', () => { - const suffix = Attribution.toDataSuffix({ + const input = { codes: ['verylongapplicationname', 'anotherlongcode'], - codeRegistryAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - }) - - // address (20 bytes) + 'verylongapplicationname,anotherlongcode' (39 bytes) + length (0x27) + schemaId (0x01) + ercSuffix - expect(suffix).toBe( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd766572796c6f6e676170706c69636174696f6e6e616d652c616e6f746865726c6f6e67636f6465270180218021802180218021802180218021', + codeRegistry: { + address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const, + chainId: 42161, + }, + id: 1 as const, + } + const suffix = Attribution.toDataSuffix(input) + const parsed = Attribution.fromData( + `0xbbbbbbbb${suffix.slice(2)}` as const, ) + expect(parsed).toEqual({ + codes: ['verylongapplicationname', 'anotherlongcode'], + codeRegistry: { + address: input.codeRegistry.address.toLowerCase(), + chainId: 42161, + }, + id: 1, + }) }) test('spec example: multiple entities', () => { - const suffix = Attribution.toDataSuffix({ + const input = { + codes: ['baseapp', 'morpho'], + codeRegistry: { + address: '0xcccccccccccccccccccccccccccccccccccccccc' as const, + chainId: 1, + }, + id: 1 as const, + } + const suffix = Attribution.toDataSuffix(input) + const parsed = Attribution.fromData( + `0xdddddddd${suffix.slice(2)}` as const, + ) + expect(parsed).toEqual({ codes: ['baseapp', 'morpho'], - codeRegistryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + codeRegistry: { + address: input.codeRegistry.address.toLowerCase(), + chainId: 1, + }, 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', - ) }) }) }) @@ -249,9 +307,13 @@ describe('fromData', () => { }) }) - test('roundtrip', () => { - const original = { - codes: ['baseapp', 'morpho', 'uniswap'], + test.each([ + [['baseapp']], + [['baseapp', 'morpho']], + [['baseapp', 'morpho', 'uniswap']], + ])('roundtrip{codes=%s}', (codes) => { + const original: Attribution.Attribution = { + codes: codes, } const suffix = Attribution.toDataSuffix(original) @@ -267,39 +329,80 @@ describe('fromData', () => { }) 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' + test('example from eip', () => { + const encoded = + '0xddddddddcccccccccccccccccccccccccccccccccccccccc210502626173656170702C6D6F7270686F0E0180218021802180218021802180218021' as const - const result = Attribution.fromData(input) + const expected = { + codes: ['baseapp', 'morpho'], + codeRegistry: { + address: '0xcccccccccccccccccccccccccccccccccccccccc', + chainId: 8453, + }, + id: 1, + } as Attribution.AttributionSchemaId1 + + const decoded = Attribution.fromData(encoded) + expect(decoded).toEqual(expected) + }) + test('multiple entities', () => { + const suffix = Attribution.toDataSuffix({ + codes: ['baseapp', 'morpho'], + codeRegistry: { + address: '0xcccccccccccccccccccccccccccccccccccccccc', + chainId: 1, + }, + id: 1, + }) + const result = Attribution.fromData( + `0xdddddddd${suffix.slice(2)}` as const, + ) expect(result).toEqual({ codes: ['baseapp', 'morpho'], - codeRegistryAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + codeRegistry: { + address: '0xcccccccccccccccccccccccccccccccccccccccc', + chainId: 1, + }, 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) + const suffix = Attribution.toDataSuffix({ + codes: ['x'], + codeRegistry: { + address: '0x1234567890123456789012345678901234567890', + chainId: 10, + }, + id: 1, + }) + const result = Attribution.fromData( + `0xdddddddd${suffix.slice(2)}` as const, + ) expect(result).toEqual({ codes: ['x'], - codeRegistryAddress: '0x1234567890123456789012345678901234567890', + codeRegistry: { + address: '0x1234567890123456789012345678901234567890', + chainId: 10, + }, id: 1, }) }) - test('roundtrip', () => { + test.each([ + 1, // 1 byte + 8453, // 2 byte + 84532, // 3 byte + ])('roundtrip with different chain ids{chainId=%i}', (chainId) => { const original = { codes: ['baseapp', 'morpho'], - codeRegistryAddress: - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as const, + codeRegistry: { + address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' as const, + chainId, + }, + id: 1 as const, } const suffix = Attribution.toDataSuffix(original) @@ -307,11 +410,7 @@ describe('fromData', () => { const parsed = Attribution.fromData(fullData) - expect(parsed).toEqual({ - codes: original.codes, - codeRegistryAddress: original.codeRegistryAddress.toLowerCase(), - id: 1, - }) + expect(parsed).toEqual(original) }) })