From 707e26e3d054f59c81d7b44128ec67c9bc2adcfe Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:05:28 +1000 Subject: [PATCH] feat: add erc7821 modules --- .changeset/weak-gifts-glow.md | 5 + src/erc7821/Calls.ts | 134 ++++++++ src/erc7821/Execute.ts | 197 +++++++++++ src/erc7821/_test/Calls.test.ts | 214 ++++++++++++ src/erc7821/_test/Execute.test.ts | 548 ++++++++++++++++++++++++++++++ src/erc7821/index.ts | 73 ++++ src/index.docs.ts | 1 + tsconfig.json | 1 + 8 files changed, 1173 insertions(+) create mode 100644 .changeset/weak-gifts-glow.md create mode 100644 src/erc7821/Calls.ts create mode 100644 src/erc7821/Execute.ts create mode 100644 src/erc7821/_test/Calls.test.ts create mode 100644 src/erc7821/_test/Execute.test.ts create mode 100644 src/erc7821/index.ts diff --git a/.changeset/weak-gifts-glow.md b/.changeset/weak-gifts-glow.md new file mode 100644 index 00000000..df43cbfe --- /dev/null +++ b/.changeset/weak-gifts-glow.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Added [ERC-7821](https://eips.ethereum.org/EIPS/eip-7821) modules. diff --git a/src/erc7821/Calls.ts b/src/erc7821/Calls.ts new file mode 100644 index 00000000..476f0d10 --- /dev/null +++ b/src/erc7821/Calls.ts @@ -0,0 +1,134 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import type * as Address from '../core/Address.js' +import type * as Hex from '../core/Hex.js' + +export type Call = { + data?: Hex.Hex | undefined + to: Address.Address + value?: bigintType | undefined +} + +/** + * Encodes a set of ERC-7821 calls. + * + * @example + * ```ts twoslash + * import { Calls } from 'ox/erc7821' + * + * const calls = Calls.encode([ + * { + * data: '0xdeadbeef', + * to: '0xcafebabecafebabecafebabecafebabecafebabe', + * value: 1n, + * }, + * { + * data: '0xcafebabe', + * to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + * value: 2n, + * }, + * ]) + * // @log: '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000cafebabecafebabecafebabecafebabecafebabe0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeef000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cafebabe' + * ``` + * + * @param calls - Calls to encode. + * @param options - Options for the encoding. + * @returns The encoded calls. + */ +export function encode(calls: readonly Call[], options: encode.Options = {}) { + const { opData } = options + return AbiParameters.encode(getAbiParameters({ opData: !!opData }), [ + calls.map((call) => ({ + target: call.to, + value: call.value ?? 0n, + data: call.data ?? '0x', + })), + ...(opData ? [opData] : []), + ] as any) +} + +export declare namespace encode { + type Options = { + /** Additional data to include for execution. */ + opData?: Hex.Hex | undefined + } +} + +export function getAbiParameters({ opData }: getAbiParameters.Options = {}) { + return AbiParameters.from([ + 'struct Call { address target; uint256 value; bytes data; }', + 'Call[] calls', + ...(opData ? ['bytes opData'] : []), + ]) +} + +export declare namespace getAbiParameters { + type Options = { + opData?: boolean | undefined + } +} + +/** + * Decodes a set of ERC-7821 calls from encoded data. + * + * @example + * ```ts twoslash + * import { Calls } from 'ox/erc7821' + * + * const data = Calls.decode('0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000') + * // @log: { + * // @log: calls: [ + * // @log: { + * // @log: data: '0xdeadbeef', + * // @log: to: '0xcafebabecafebabecafebabecafebabecafebabe', + * // @log: value: 1n, + * // @log: }, + * // @log: { + * // @log: data: '0xcafebabe', + * // @log: to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + * // @log: value: 2n, + * // @log: }, + * // @log: ] + * // @log: } + * ``` + * + * @param data - The encoded calls data. + * @param options - Options for decoding. + * @returns The decoded calls and optional opData. + */ +export function decode( + data: Hex.Hex, + options: decode.Options = {}, +): decode.ReturnType { + const { opData: withOpData = false } = options + + const decoded = AbiParameters.decode( + getAbiParameters({ opData: withOpData }), + data, + ) as readonly unknown[] + const [encodedCalls, opData] = decoded as readonly [ + { target: Address.Address; value: bigint; data: Hex.Hex }[], + Hex.Hex?, + ] + + const calls = encodedCalls.map((call) => ({ + to: call.target, + value: call.value, + data: call.data, + })) + + return withOpData + ? { calls, opData: opData === '0x' ? undefined : opData } + : { calls } +} + +export declare namespace decode { + type Options = { + /** Whether to decode opData if present. */ + opData?: boolean | undefined + } + + type ReturnType = { + calls: Call[] + opData?: Hex.Hex | undefined + } +} diff --git a/src/erc7821/Execute.ts b/src/erc7821/Execute.ts new file mode 100644 index 00000000..defd12be --- /dev/null +++ b/src/erc7821/Execute.ts @@ -0,0 +1,197 @@ +import * as AbiFunction from '../core/AbiFunction.js' +import type * as Hex from '../core/Hex.js' +import { AbiParameters } from '../index.js' +import * as Calls from './Calls.js' + +export type Batch = { + calls: readonly Call[] + opData?: Hex.Hex | undefined +} + +export type Call = Calls.Call + +export const abiFunction = { + type: 'function', + name: 'execute', + inputs: [ + { + name: 'mode', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'executionData', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'payable', +} satisfies AbiFunction.AbiFunction + +export const mode = { + default: '0x0100000000000000000000000000000000000000000000000000000000000000', + opData: '0x0100000000007821000100000000000000000000000000000000000000000000', + batchOfBatches: + '0x0100000000007821000200000000000000000000000000000000000000000000', +} as const + +/** + * Decodes calls from ERC-7821 `execute` function data. + * + * @example + * ```ts twoslash + * import { Execute } from 'ox/erc7821' + * + * const { calls } = Execute.decodeData('0x...') + * ``` + * + * @param data - The encoded data. + * @returns The decoded calls and optional opData. + */ +export function decodeData(data: Hex.Hex): decodeData.ReturnType { + const [m, executionData] = AbiFunction.decodeData(abiFunction, data) as [ + Hex.Hex, + Hex.Hex, + ] + return Calls.decode(executionData, { opData: m !== mode.default }) +} + +export declare namespace decodeData { + type ReturnType = { + calls: Call[] + opData?: Hex.Hex | undefined + } +} + +/** + * Decodes batches from ERC-7821 `execute` function data in "batch of batches" mode. + * + * @example + * ```ts twoslash + * import { Execute } from 'ox/erc7821' + * + * const batches = Execute.decodeBatchOfBatchesData('0x...') + * ``` + * + * @param data - The encoded data. + * @returns The decoded batches. + */ +export function decodeBatchOfBatchesData( + data: Hex.Hex, +): decodeBatchOfBatchesData.ReturnType { + const [, executionData] = AbiFunction.decodeData(abiFunction, data) as [ + Hex.Hex, + Hex.Hex, + ] + + const [encodedBatches] = AbiParameters.decode( + AbiParameters.from('bytes[]'), + executionData, + ) as readonly [Hex.Hex[]] + + return encodedBatches.map((encodedBatch) => { + // Try decoding with opData first + try { + const decoded = Calls.decode(encodedBatch, { opData: true }) + if (decoded.opData) { + return { + calls: decoded.calls, + opData: decoded.opData, + } + } + // If opData is undefined, return without it + return { calls: decoded.calls } + } catch { + // If decoding with opData fails, decode without it + const decoded = Calls.decode(encodedBatch, { opData: false }) + return { calls: decoded.calls } + } + }) +} + +export declare namespace decodeBatchOfBatchesData { + type ReturnType = Batch[] +} + +/** + * Encodes calls for the ERC-7821 `execute` function with "batch of batches" mode. + * + * @example + * ```ts twoslash + * import { Execute } from 'ox/erc7821' + * + * const data = Execute.encodeBatchOfBatchesData([ + * { + * calls: [ + * { + * data: '0xcafebabe', + * to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + * value: 1n, + * } + * ] + * }, + * { + * calls: [ + * { + * data: '0xdeadbeef', + * to: '0xcafebabecafebabecafebabecafebabecafebabe', + * value: 2n, + * } + * ], + * opData: '0xcafebabe', + * } + * ]) + * ``` + * + * @param calls - The calls to encode. + * @param options - The options. + * @returns The encoded data. + */ +export function encodeBatchOfBatchesData(batches: readonly Batch[]) { + const b = AbiParameters.encode(AbiParameters.from('bytes[]'), [ + batches.map((b) => { + const batch = b as Batch + return Calls.encode(batch.calls, { + opData: batch.opData, + }) + }), + ]) + return AbiFunction.encodeData(abiFunction, [mode.batchOfBatches, b]) +} + +/** + * Encodes calls for the ERC-7821 `execute` function. + * + * @example + * ```ts twoslash + * import { Execute } from 'ox/erc7821' + * + * const data = Execute.encodeData([ + * { + * data: '0xcafebabe', + * to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + * value: 1n, + * } + * ]) + * ``` + * + * @param calls - The calls to encode. + * @param options - The options. + * @returns The encoded data. + */ +export function encodeData( + calls: readonly Call[], + options: encodeData.Options = {}, +) { + const { opData } = options + const c = Calls.encode(calls, { opData }) + const m = opData ? mode.opData : mode.default + return AbiFunction.encodeData(abiFunction, [m, c]) +} + +export declare namespace encodeData { + type Options = { + opData?: Hex.Hex | undefined + } +} diff --git a/src/erc7821/_test/Calls.test.ts b/src/erc7821/_test/Calls.test.ts new file mode 100644 index 00000000..292200da --- /dev/null +++ b/src/erc7821/_test/Calls.test.ts @@ -0,0 +1,214 @@ +import { Calls } from 'ox/erc7821' +import { describe, expect, test } from 'vitest' + +describe('encode', () => { + test('default', () => { + expect( + Calls.encode([ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 2n, + }, + ]), + ).toMatchInlineSnapshot( + `"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: single call', () => { + expect( + Calls.encode([ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1000000000000000000n, + }, + ]), + ).toMatchInlineSnapshot( + `"0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: no data', () => { + expect( + Calls.encode([ + { + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + { + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 2n, + }, + ]), + ).toMatchInlineSnapshot( + `"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: no value', () => { + expect( + Calls.encode([ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + }, + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }, + ]), + ).toMatchInlineSnapshot( + `"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: empty calls array', () => { + expect(Calls.encode([])).toMatchInlineSnapshot( + `"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: with opData', () => { + expect( + Calls.encode( + [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + { opData: '0x1234567890abcdef' }, + ), + ).toMatchInlineSnapshot( + `"0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081234567890abcdef000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: with empty opData', () => { + expect( + Calls.encode( + [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + { opData: '0x' }, + ), + ).toMatchInlineSnapshot( + `"0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: mixed calls with and without data/value', () => { + expect( + Calls.encode([ + { + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 100n, + }, + { + data: '0xdeadbeef', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }, + { + data: '0xcafebabe', + to: '0x1234567890123456789012345678901234567890', + value: 200n, + }, + ]), + ).toMatchInlineSnapshot( + `"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000180000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000000c800000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000"`, + ) + }) +}) + +describe('decode', () => { + test('default', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 2n, + }, + ] as const + + const encoded = Calls.encode(calls) + const result = Calls.decode(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: single call', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1000000000000000000n, + }, + ] as const + + const encoded = Calls.encode(calls) + const result = Calls.decode(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: empty calls', () => { + const calls = [] as const + + const encoded = Calls.encode(calls) + const result = Calls.decode(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: with opData', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ] as const + const opData = '0x1234567890abcdef' as const + + const encoded = Calls.encode(calls, { opData }) + const result = Calls.decode(encoded, { opData: true }) + + expect(result).toEqual({ calls, opData }) + }) + + test('behavior: with empty opData', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ] as const + const opData = '0x' as const + + const encoded = Calls.encode(calls, { opData }) + const result = Calls.decode(encoded, { opData: true }) + + expect(result).toEqual({ + calls, + opData: undefined, + }) + }) +}) diff --git a/src/erc7821/_test/Execute.test.ts b/src/erc7821/_test/Execute.test.ts new file mode 100644 index 00000000..09a2ee21 --- /dev/null +++ b/src/erc7821/_test/Execute.test.ts @@ -0,0 +1,548 @@ +import { Execute } from 'ox/erc7821' +import { describe, expect, test } from 'vitest' + +describe('encodeData', () => { + test('default', () => { + expect( + Execute.encodeData([ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 2n, + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: single call', () => { + expect( + Execute.encodeData([ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1000000000000000000n, + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c53010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: no data', () => { + expect( + Execute.encodeData([ + { + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + { + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 2n, + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c5301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: no value', () => { + expect( + Execute.encodeData([ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + }, + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: empty calls array', () => { + expect(Execute.encodeData([])).toMatchInlineSnapshot( + `"0xe9ae5c5301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: with opData', () => { + expect( + Execute.encodeData( + [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + { opData: '0x1234567890abcdef' }, + ), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000007821000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081234567890abcdef000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: with empty opData', () => { + expect( + Execute.encodeData( + [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + { opData: '0x' }, + ), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000007821000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: mixed calls with and without data/value', () => { + expect( + Execute.encodeData([ + { + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 100n, + }, + { + data: '0xdeadbeef', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }, + { + data: '0xcafebabe', + to: '0x1234567890123456789012345678901234567890', + value: 200n, + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c5301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000180000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000000c800000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000"`, + ) + }) +}) + +describe('decodeData', () => { + test('default', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 2n, + }, + ] as const + + const encoded = Execute.encodeData(calls) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: single call', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1000000000000000000n, + }, + ] as const + + const encoded = Execute.encodeData(calls) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: empty calls', () => { + const calls = [] as const + + const encoded = Execute.encodeData(calls) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: with opData', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ] as const + const opData = '0x1234567890abcdef' as const + + const encoded = Execute.encodeData(calls, { opData }) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ calls, opData }) + }) + + test('behavior: with empty opData', () => { + const calls = [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ] as const + const opData = '0x' as const + + const encoded = Execute.encodeData(calls, { opData }) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ + calls, + opData: undefined, + }) + }) + + test('behavior: no data or value', () => { + const calls = [ + { + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 0n, + data: '0x', + }, + ] as const + + const encoded = Execute.encodeData(calls) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ calls }) + }) + + test('behavior: round trip with mixed calls', () => { + const calls = [ + { + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 100n, + data: '0x', + }, + { + data: '0xdeadbeef', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 0n, + }, + { + data: '0xcafebabe', + to: '0x1234567890123456789012345678901234567890', + value: 200n, + }, + ] as const + + const encoded = Execute.encodeData(calls) + const result = Execute.decodeData(encoded) + + expect(result).toEqual({ calls }) + }) +}) + +describe('encodeBatchOfBatchesData', () => { + test('default', () => { + expect( + Execute.encodeBatchOfBatchesData([ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 1n, + }, + ], + }, + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 2n, + }, + ], + opData: '0x1234567890abcdef', + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c5301000000000078210002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081234567890abcdef000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: single batch', () => { + expect( + Execute.encodeBatchOfBatchesData([ + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000007821000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: batch without opData', () => { + expect( + Execute.encodeBatchOfBatchesData([ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 1n, + }, + ], + }, + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 2n, + }, + ], + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000007821000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: batch with empty opData', () => { + expect( + Execute.encodeBatchOfBatchesData([ + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + opData: '0x', + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c530100000000007821000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: multiple calls per batch', () => { + expect( + Execute.encodeBatchOfBatchesData([ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 1n, + }, + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 2n, + }, + ], + }, + ]), + ).toMatchInlineSnapshot( + `"0xe9ae5c5301000000000078210002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004cafebabe00000000000000000000000000000000000000000000000000000000000000000000000000000000cafebabecafebabecafebabecafebabecafebabe000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000"`, + ) + }) + + test('behavior: empty batches array', () => { + expect(Execute.encodeBatchOfBatchesData([])).toMatchInlineSnapshot( + `"0xe9ae5c5301000000000078210002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"`, + ) + }) +}) + +describe('decodeBatchOfBatchesData', () => { + test('default', () => { + const batches = [ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 1n, + }, + ], + }, + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 2n, + }, + ], + opData: '0x1234567890abcdef', + }, + ] as const + + const encoded = Execute.encodeBatchOfBatchesData(batches) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) + + test('behavior: single batch', () => { + const batches = [ + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + }, + ] as const + + const encoded = Execute.encodeBatchOfBatchesData(batches) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) + + test('behavior: batch without opData', () => { + const batches = [ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 1n, + }, + ], + }, + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 2n, + }, + ], + }, + ] as const + + const encoded = Execute.encodeBatchOfBatchesData(batches) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) + + test('behavior: batch with empty opData', () => { + const batches = [ + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + }, + ], + }, + ] as const + const opData = '0x' as const + + const encoded = Execute.encodeBatchOfBatchesData([ + { calls: batches[0].calls, opData }, + ]) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) + + test('behavior: multiple calls per batch', () => { + const batches = [ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 1n, + }, + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 2n, + }, + ], + }, + ] as const + + const encoded = Execute.encodeBatchOfBatchesData(batches) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) + + test('behavior: empty batches', () => { + const batches = [] as const + + const encoded = Execute.encodeBatchOfBatchesData(batches) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) + + test('behavior: mixed batches with and without opData', () => { + const batches = [ + { + calls: [ + { + data: '0xcafebabe', + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + value: 100n, + }, + ], + }, + { + calls: [ + { + data: '0xdeadbeef', + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 200n, + }, + ], + opData: '0xabcdef', + }, + { + calls: [ + { + to: '0x1234567890123456789012345678901234567890', + value: 300n, + data: '0x', + }, + ], + }, + ] as const + + const encoded = Execute.encodeBatchOfBatchesData(batches) + const result = Execute.decodeBatchOfBatchesData(encoded) + + expect(result).toEqual(batches) + }) +}) diff --git a/src/erc7821/index.ts b/src/erc7821/index.ts new file mode 100644 index 00000000..06c2d465 --- /dev/null +++ b/src/erc7821/index.ts @@ -0,0 +1,73 @@ +/** @entrypointCategory ERCs */ +// biome-ignore lint/complexity/noUselessEmptyExport: tsdoc +export type {} + +/** + * Utility functions for encoding and decoding [ERC-7821](https://eips.ethereum.org/EIPS/eip-7821) calls. + * + * @example + * ### Encoding calls + * + * Calls can be encoded using `Calls.encode`. + * + * ```ts twoslash + * import { Calls } from 'ox/erc7821' + * + * const calls = Calls.encode([ + * { + * data: '0xcafebabe', + * to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + * value: 1n, + * } + * ]) + * ``` + * + * @example + * ### Decoding calls + * + * Calls can be decoded using `Calls.decode`. + * + * ```ts twoslash + * import { Calls } from 'ox/erc7821' + * + * const { calls } = Calls.decode('0x...') + * ``` + * + * @category ERC-7821 + */ +export * as Calls from './Calls.js' + +/** + * Utility functions for encoding and decoding [ERC-7821](https://eips.ethereum.org/EIPS/eip-7821) `execute` function data. + * + * @example + * ### Encoding `execute` Function Data + * + * The `execute` function data can be encoded using `Execute.encodeData`. + * + * ```ts twoslash + * import { Execute } from 'ox/erc7821' + * + * const data = Execute.encodeData([ + * { + * data: '0xcafebabe', + * to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + * value: 1n, + * } + * ]) + * ``` + * + * @example + * ### Decoding `execute` Function Data + * + * The `execute` function data can be decoded using `Execute.decodeData`. + * + * ```ts twoslash + * import { Execute } from 'ox/erc7821' + * + * const { calls } = Execute.decodeData('0xe9ae5c53...') + * ``` + * + * @category ERC-7821 + */ +export * as Execute from './Execute.js' diff --git a/src/index.docs.ts b/src/index.docs.ts index 272a2097..7b726e1d 100644 --- a/src/index.docs.ts +++ b/src/index.docs.ts @@ -4,4 +4,5 @@ export * from './index.js' export * from './erc4337/index.js' export * from './erc6492/index.js' +export * from './erc7821/index.js' export * from './erc8010/index.js' diff --git a/tsconfig.json b/tsconfig.json index 84c12cbb..dece00da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "ox": ["src/index.ts"], "ox/erc4337": ["src/erc4337/index.ts"], "ox/erc6492": ["src/erc6492/index.ts"], + "ox/erc7821": ["src/erc7821/index.ts"], "ox/erc8010": ["src/erc8010/index.ts"], "ox/trusted-setups": ["src/trusted-setups/index.ts"], "ox/window": ["src/window/index.ts"]