From 6a148fe3bd6b1e435d96b8e3f8d6dbdeaa8280c6 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Sun, 18 Jan 2026 19:43:38 -0300 Subject: [PATCH 01/21] evm: implement dynamic call encoder library --- .../dynamic-calls/DynamicCallEncoder.sol | 249 ++++++++++++++++++ .../dynamic-calls/DynamicCallTypes.sol | 64 +++++ .../test/dynamic-calls/StaticCallMock.sol | 17 ++ packages/evm/contracts/utils/BytesHelpers.sol | 84 ++++++ .../dynamic-calls/DynamicCallResolver.test.ts | 220 ++++++++++++++++ packages/evm/test/helpers/dynamic-calls.ts | 21 ++ packages/evm/test/helpers/index.ts | 1 + 7 files changed, 656 insertions(+) create mode 100644 packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol create mode 100644 packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol create mode 100644 packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol create mode 100644 packages/evm/contracts/utils/BytesHelpers.sol create mode 100644 packages/evm/test/dynamic-calls/DynamicCallResolver.test.ts create mode 100644 packages/evm/test/helpers/dynamic-calls.ts diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol new file mode 100644 index 0000000..4967307 --- /dev/null +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +import './DynamicCallTypes.sol'; +import '../utils/BytesHelpers.sol'; + +/** + * @title DynamicCallEncoder + * @dev Builds calldata for arbitrary contract calls from structured arguments. + * + * This encoder supports: + * - Literal ABI-encoded arguments + * - Variable references resolved from previous execution results + * - Nested static calls whose return values are used as arguments + * + * The encoder follows standard ABI encoding rules, reconstructing + * the calldata heads and tails dynamically based on argument types. + */ +contract DynamicCallEncoder { + using BytesHelpers for bytes; + + /// @dev Thrown when an argument is not word-aligned + error DynamicCallEncoderBadLength(); + + /// @dev Thrown when a dynamic value resolves to empty data + error DynamicCallEncoderEmptyDynamic(); + + /// @dev Thrown when a static literal has an invalid size prefix + error DynamicCallEncoderBadStaticSize(); + + /// @dev Thrown when a static literal does not end with a zero word + error DynamicCallEncoderBadStaticTrailer(); + + /// @dev Thrown when a static literal is too short to be valid + error DynamicCallEncoderTooShortStatic(); + + /// @dev Thrown when a variable reference is not exactly one word + error DynamicCallEncoderVariableRefBadLength(); + + /// @dev Thrown when a variable index is outside the variables array + error DynamicCallEncoderVariableOutOfBounds(); + + /// @dev Thrown when a variable value is too short to be interpreted + error DynamicCallEncoderVariableTooShort(); + + /// @dev Thrown when a static call argument cannot be decoded + error DynamicCallEncoderStaticCallBadSpec(); + + /// @dev Thrown when a staticcall execution fails + error DynamicCallEncoderStaticCallFailed(address target); + + /** + * @dev Internal representation of a fully-encoded argument + * @param data ABI-encoded argument payload: + * - static: inline ABI words + * - dynamic: tail data ([len][data...]) + * @param isDynamic Whether this argument requires a head offset + * @param headLength Bytes contributed to the calldata head + */ + struct EncodedArg { + bytes data; + bool isDynamic; + uint256 headLength; + } + + /** + * @dev Encodes a dynamic call into calldata + * @param call_ Dynamic call specification + * @param variables List of resolved variable values + * @return data Fully ABI-encoded calldata + */ + function encode(DynamicCall memory call_, bytes[] memory variables) external view returns (bytes memory data) { + data = _buildCalldata(call_.selector, call_.arguments, variables); + } + + /** + * @dev Builds calldata from a selector and a list of dynamic arguments + * This function performs standard ABI aggregation: + * - static arguments are inlined in the head + * - dynamic arguments place offsets in the head and append data to the tail + */ + function _buildCalldata(bytes4 selector, DynamicArg[] memory args, bytes[] memory variables) + internal + view + returns (bytes memory data) + { + uint256 n = args.length; + bytes[] memory encodedArgs = new bytes[](n); + bool[] memory isDynamic = new bool[](n); + uint256 headLength = 0; + + for (uint256 i = 0; i < n; i++) { + EncodedArg memory enc = _encodeArg(args[i], variables); + encodedArgs[i] = enc.data; + isDynamic[i] = enc.isDynamic; + headLength += enc.headLength; + } + + bytes memory heads; + bytes memory tails; + uint256 nextDynamicHead = headLength; + + for (uint256 i = 0; i < n; i++) { + if (isDynamic[i]) { + heads = bytes.concat(heads, bytes32(nextDynamicHead)); + tails = bytes.concat(tails, encodedArgs[i]); + nextDynamicHead += encodedArgs[i].length; + } else { + heads = bytes.concat(heads, encodedArgs[i]); + } + } + + data = bytes.concat(selector, heads, tails); + } + + /** + * @dev Encodes a single dynamic argument based on its kind + */ + function _encodeArg(DynamicArg memory arg, bytes[] memory variables) internal view returns (EncodedArg memory out) { + if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg.data); + if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables); + if (arg.kind == DynamicArgKind.StaticCall) return _encodeStaticCall(arg.data, variables); + revert DynamicCallEncoderStaticCallBadSpec(); + } + + /** + * @dev Encodes a literal argument. It supports: + * - Static values encoded as [size][data][0] + * - Dynamic values pre-encoded with a dynamic ABI prefix + */ + function _encodeLiteral(bytes memory argument) internal pure returns (EncodedArg memory out) { + if (argument.length % 32 != 0) revert DynamicCallEncoderBadLength(); + + if (_hasDynamicPrefix(argument)) { + // Dynamic literal: remove pre-encoding prefix + bytes memory encodedArg = argument.sliceFrom(96); + if (encodedArg.length == 0) revert DynamicCallEncoderEmptyDynamic(); + + out.data = encodedArg; + out.isDynamic = true; + out.headLength = 32; + } else { + // Static literal: [size][data][zero] + if (argument.length < 64) revert DynamicCallEncoderTooShortStatic(); + + uint256 staticSize = argument.readWord0(); + if (argument.length != staticSize + 32) revert DynamicCallEncoderBadStaticSize(); + if (!argument.lastWordIsZero()) revert DynamicCallEncoderBadStaticTrailer(); + + bytes memory encodedArg = argument.slice(32, argument.length - 32); + out.data = encodedArg; + out.isDynamic = false; + out.headLength = encodedArg.length; + } + } + + /** + * @dev Encodes a variable argument by resolving it from the variables list + */ + function _encodeVariable(bytes memory data, bytes[] memory variables) + internal + pure + returns (EncodedArg memory out) + { + if (data.length != 32) revert DynamicCallEncoderVariableRefBadLength(); + uint256 index = data.readWord0(); + if (index >= variables.length) revert DynamicCallEncoderVariableOutOfBounds(); + out = _encodeFromAbiLikeBytes(variables[index]); + } + + /** + * @dev Encodes a staticcall argument + * Executes a staticcall and interprets the return data as an ABI value + */ + function _encodeStaticCall(bytes memory data, bytes[] memory variables) + internal + view + returns (EncodedArg memory out) + { + if (data.length < 64) revert DynamicCallEncoderStaticCallBadSpec(); + DynamicStaticCallArg memory spec = abi.decode(data, (DynamicStaticCallArg)); + bytes memory callData = _buildCalldata(spec.selector, spec.arguments, variables); + (bool ok, bytes memory result) = spec.target.staticcall(callData); + if (!ok) revert DynamicCallEncoderStaticCallFailed(spec.target); + out = _encodeFromAbiLikeBytes(result); + } + + /** + * @dev Interprets ABI-like bytes as either a static or dynamic value + * Used for variable resolution and staticcall return values + */ + function _encodeFromAbiLikeBytes(bytes memory value) internal pure returns (EncodedArg memory out) { + if (value.length < 32) revert DynamicCallEncoderVariableTooShort(); + + if (_looksLikeSingleDynamicAbiValue(value)) { + bytes memory tail = value.sliceFrom(32); + if (tail.length == 0) revert DynamicCallEncoderEmptyDynamic(); + out.data = tail; + out.isDynamic = true; + out.headLength = 32; + } else { + out.data = value.slice(0, 32); + out.isDynamic = false; + out.headLength = 32; + } + } + + /** + * @dev Detects ABI encoding of a single dynamic return value + */ + function _looksLikeSingleDynamicAbiValue(bytes memory data) private pure returns (bool) { + if (data.length < 64) return false; + if (data.length % 32 != 0) return false; + return data.readWord0() == 0x20; + } + + /** + * @dev Detects the dynamic pre-encoding prefix used by abi.encode("", value) + */ + function _hasDynamicPrefix(bytes memory argument) private pure returns (bool) { + if (argument.length < 96) return false; + + bytes32 w0; + bytes32 w1; + bytes32 w2; + + assembly { + let off := add(argument, 32) + w0 := mload(off) + w1 := mload(add(off, 32)) + w2 := mload(add(off, 64)) + } + + return (uint256(w0) == 0x40) && (uint256(w1) == 0x60) && (w2 == bytes32(0)); + } +} diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol new file mode 100644 index 0000000..3f19329 --- /dev/null +++ b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +/** + * @dev Kind of dynamic argument to be encoded + * @param Literal ABI-encoded literal value provided by the resolver + * @param Variable Reference to a previously resolved variable value + * @param StaticCall Result of executing a static call at encoding time + */ +enum DynamicArgKind { + Literal, + Variable, + StaticCall +} + +/** + * @dev Specification for a static call whose return value + * will be used as an argument in another call + * @param target Contract to be called via staticcall + * @param selector Function selector to invoke + * @param arguments Arguments to be encoded and passed to the static call + */ +struct DynamicStaticCallArg { + address target; + bytes4 selector; + DynamicArg[] arguments; +} + +/** + * @dev Represents a single dynamic argument + * @param kind Type of argument resolution strategy + * @param data Encoded argument data, interpreted based on `kind` + */ +struct DynamicArg { + DynamicArgKind kind; + bytes data; +} + +/** + * @dev Represents a dynamic contract call intent + * @param target Contract address to be called + * @param value ETH value to be sent with the call + * @param selector Function selector to invoke + * @param arguments List of dynamically resolved arguments + */ +struct DynamicCall { + address target; + uint256 value; + bytes4 selector; + DynamicArg[] arguments; +} diff --git a/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol new file mode 100644 index 0000000..55f7d7d --- /dev/null +++ b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +contract StaticCallMock { + function returnUint(uint256 value) external pure returns (uint256) { + return value; + } + + function returnAddress(address value) external pure returns (address) { + return value; + } + + function returnArray(uint256[] calldata value) external pure returns (uint256[] memory) { + return value; + } +} diff --git a/packages/evm/contracts/utils/BytesHelpers.sol b/packages/evm/contracts/utils/BytesHelpers.sol new file mode 100644 index 0000000..6c8d1d1 --- /dev/null +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +/** + * @title BytesHelpers + * @dev Collection of low-level helpers to operate on `bytes` values in memory. + */ +library BytesHelpers { + /// @dev Thrown when a slice operation exceeds the bounds of the input bytes + error BytesLibSliceOutOfBounds(); + + /** + * @dev Reads the first 32-byte word of a bytes array + * @param data Bytes array to read from + * @return result First ABI word of `data` + */ + function readWord0(bytes memory data) internal pure returns (uint256 result) { + assembly { + result := mload(add(data, 32)) + } + } + + /** + * @dev Checks whether the last 32-byte word of a bytes array is zero + * + * Commonly used to validate ABI-encoded static values, which must + * end with a zero padding word. + */ + function lastWordIsZero(bytes memory data) internal pure returns (bool) { + bytes32 last; + assembly { + last := mload(add(data, mload(data))) + } + return last == bytes32(0); + } + + /** + * @dev Returns a slice of a bytes array from `start` (inclusive) to `end` (exclusive) + * @param data Bytes array to slice + * @param start Starting byte index (inclusive) + * @param end Ending byte index (exclusive) + */ + function slice(bytes memory data, uint256 start, uint256 end) internal pure returns (bytes memory out) { + if (end < start) revert BytesLibSliceOutOfBounds(); + if (end > data.length) revert BytesLibSliceOutOfBounds(); + + uint256 len = end - start; + out = new bytes(len); + + assembly { + let src := add(add(data, 32), start) + let dst := add(out, 32) + for { + let i := 0 + } lt(i, len) { + i := add(i, 32) + } { + mstore(add(dst, i), mload(add(src, i))) + } + } + } + + /** + * @dev Returns a slice of a bytes array starting at `start` until the end + * @param data Bytes array to slice + * @param start Starting byte index (inclusive) + */ + function sliceFrom(bytes memory data, uint256 start) internal pure returns (bytes memory out) { + return slice(data, start, data.length); + } +} diff --git a/packages/evm/test/dynamic-calls/DynamicCallResolver.test.ts b/packages/evm/test/dynamic-calls/DynamicCallResolver.test.ts new file mode 100644 index 0000000..1c65928 --- /dev/null +++ b/packages/evm/test/dynamic-calls/DynamicCallResolver.test.ts @@ -0,0 +1,220 @@ +import { randomEvmAddress } from '@mimicprotocol/sdk' +import { expect } from 'chai' +import { network } from 'hardhat' + +import { DynamicCallEncoder, StaticCallMock } from '../../types/ethers-contracts/index.js' +import { DynamicArg, literal, staticCall, variable } from '../helpers' + +const { ethers } = await network.connect() + +/* eslint-disable no-secrets/no-secrets */ + +describe('DynamicCallEncoder', () => { + let encoder: DynamicCallEncoder + + beforeEach('deploy contract', async () => { + encoder = await ethers.deployContract('DynamicCallEncoder') + }) + + const iface = new ethers.Interface([ + 'function balanceOf(address) view returns (uint256)', + 'function transfer(address,uint256) returns (bool)', + 'function foo(uint256[])', + ]) + + function dynamicCall(method: string, args: DynamicArg[]) { + return { + target: randomEvmAddress(), + value: 0n, + selector: iface.getFunction(method)!.selector, + arguments: args, + } + } + + describe('encode', () => { + context('with literal arguments', () => { + const variables: string[] = [] + + context('with a single argument', () => { + const owner = randomEvmAddress() + const call = dynamicCall('balanceOf', [literal(['address'], [owner])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [owner])) + }) + }) + + context('with multiple arguments', () => { + const to = randomEvmAddress() + const amount = 999n + const call = dynamicCall('transfer', [literal(['address'], [to]), literal(['uint256'], [amount])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, amount])) + }) + }) + + context('with arbitrary-length arguments', () => { + const values = [1n, 2n, 3n] + const call = dynamicCall('foo', [literal(['uint256[]'], [values])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) + }) + }) + }) + + context('with variable arguments', () => { + context('when the variable spec is correct', () => { + const var0 = 100n + const var1 = randomEvmAddress() + const var2 = [1, 2, 3, 4, 5, 6, 7] + + const variables = [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [var0]), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [var1]), + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[]'], [var2]), + ] + + context('with a single argument', () => { + const call = dynamicCall('balanceOf', [variable(1)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [var1])) + }) + }) + + context('with multiple arguments', () => { + const to = randomEvmAddress() + const call = dynamicCall('transfer', [literal(['address'], [to]), variable(0)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, var0])) + }) + }) + + context('with arbitrary-length arguments', () => { + const call = dynamicCall('foo', [variable(2)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('foo', [var2])) + }) + }) + }) + + context('when the variable spec is invalid', () => { + context('when variable ref is not 32 bytes', () => { + const call = dynamicCall('foo', [{ kind: 1, data: '0x11' }]) + + it('reverts with DynamicCallEncoderVariableRefBadLength', async () => { + await expect(encoder.encode(call, [])).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariableRefBadLength' + ) + }) + }) + + context('when variable index is out of bounds', () => { + const call = dynamicCall('foo', [variable(0)]) + + it('reverts with DynamicCallEncoderVariableOutOfBounds', async () => { + await expect(encoder.encode(call, [])).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariableOutOfBounds' + ) + }) + }) + + context('when variable bytes are too short to be static', () => { + const variables = ['0x1234'] + const call = dynamicCall('transfer', [literal(['address'], [randomEvmAddress()]), variable(0)]) + + it('reverts with DynamicCallEncoderVariableTooShort', async () => { + await expect(encoder.encode(call, variables)).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariableTooShort' + ) + }) + }) + }) + }) + + context('with staticcall arguments', () => { + let mock: StaticCallMock + + beforeEach('deploy static call mock', async () => { + mock = await ethers.deployContract('StaticCallMock') + }) + + context('when the staticcall receives a literal', () => { + context('with fixed-length return types', () => { + it('encodes arguments properly', async () => { + const to = randomEvmAddress() + const amount = 999n + + const call = dynamicCall('transfer', [ + staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [ + literal(['address'], [to]), + ]), + literal(['uint256'], [amount]), + ]) + const encoded = await encoder.encode(call, []) + expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, amount])) + }) + }) + + context('with arbitrary-length return types', () => { + it('encodes arguments properly', async () => { + const values = [1n, 2n, 3n] + + const call = dynamicCall('foo', [ + staticCall(mock.target, mock.interface.getFunction('returnArray')!.selector, [ + literal(['uint256[]'], [values]), + ]), + ]) + + const encoded = await encoder.encode(call, []) + expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) + }) + }) + }) + + context('when the staticcall receives a variable', () => { + it('encodes arguments properly', async () => { + const owner = randomEvmAddress() + const variables = [ethers.AbiCoder.defaultAbiCoder().encode(['address'], [owner])] + + const call = dynamicCall('balanceOf', [ + staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [variable(0)]), + ]) + + const encoded = await encoder.encode(call, variables) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [owner])) + }) + }) + + context('when the staticcall receives the result of another staticcall', () => { + it('encodes arguments properly', async () => { + const to = randomEvmAddress() + + const call = dynamicCall('balanceOf', [ + staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [ + staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [ + literal(['address'], [to]), + ]), + ]), + ]) + + const encoded = await encoder.encode(call, []) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [to])) + }) + }) + }) + }) +}) diff --git a/packages/evm/test/helpers/dynamic-calls.ts b/packages/evm/test/helpers/dynamic-calls.ts new file mode 100644 index 0000000..8580af5 --- /dev/null +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -0,0 +1,21 @@ +import { AbiCoder } from 'ethers' + +export type DynamicArg = { kind: number; data: string } + +export function literal(types: string[], values: any[]): DynamicArg { + const data = AbiCoder.defaultAbiCoder().encode(['string', ...types], ['', ...values]) + return { kind: 0, data } +} + +export function variable(index: number): DynamicArg { + const data = AbiCoder.defaultAbiCoder().encode(['uint256'], [index]) + return { kind: 1, data } +} + +export function staticCall(target: string, selector: string, args: DynamicArg[]) { + const data = AbiCoder.defaultAbiCoder().encode( + ['tuple(address target, bytes4 selector, tuple(uint8 kind, bytes data)[] arguments)'], + [{ target, selector, arguments: args }] + ) + return { kind: 2, data } +} diff --git a/packages/evm/test/helpers/index.ts b/packages/evm/test/helpers/index.ts index 43b47ac..16160be 100644 --- a/packages/evm/test/helpers/index.ts +++ b/packages/evm/test/helpers/index.ts @@ -1,5 +1,6 @@ export * from './addresses' export * from './arrays' +export * from './dynamic-calls.js' export * from './intents' export * from './proposal' export * from './safeguards' From 1f0a4900b118a1318b9af50a2c6e37192827e8f3 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Tue, 20 Jan 2026 09:13:58 -0300 Subject: [PATCH 02/21] evm: add bytes helpers tests --- .../contracts/test/utils/BytesHelpersMock.sol | 37 +++++ packages/evm/test/utils/BytesHelpers.test.ts | 139 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/evm/contracts/test/utils/BytesHelpersMock.sol create mode 100644 packages/evm/test/utils/BytesHelpers.test.ts diff --git a/packages/evm/contracts/test/utils/BytesHelpersMock.sol b/packages/evm/contracts/test/utils/BytesHelpersMock.sol new file mode 100644 index 0000000..5ff08b3 --- /dev/null +++ b/packages/evm/contracts/test/utils/BytesHelpersMock.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +import '../../utils/BytesHelpers.sol'; + +contract BytesHelpersMock { + using BytesHelpers for bytes; + + function readWord0(bytes memory data) external pure returns (uint256) { + return data.readWord0(); + } + + function lastWordIsZero(bytes memory data) external pure returns (bool) { + return data.lastWordIsZero(); + } + + function slice(bytes memory data, uint256 start, uint256 end) external pure returns (bytes memory) { + return data.slice(start, end); + } + + function sliceFrom(bytes memory data, uint256 start) external pure returns (bytes memory) { + return data.sliceFrom(start); + } +} diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts new file mode 100644 index 0000000..2a91016 --- /dev/null +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -0,0 +1,139 @@ +import { expect } from 'chai' +import { AbiCoder, Contract } from 'ethers' +import { network } from 'hardhat' + +const { ethers } = await network.connect() + +/* eslint-disable no-secrets/no-secrets */ + +describe('BytesHelpers', () => { + let library: Contract + + beforeEach('deploy helpers mock', async () => { + library = await ethers.deployContract('BytesHelpersMock') + }) + + describe('readWord0', () => { + context('when data is 32 bytes', () => { + const word = 123n + const data = AbiCoder.defaultAbiCoder().encode(['uint256'], [word]) + + it('returns the first word', async () => { + expect(await library.readWord0(data)).to.equal(word) + }) + }) + + context('when data is longer than 32 bytes', () => { + const a = 999n + const b = 555n + const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [a, b]) + + it('returns the first word', async () => { + expect(await library.readWord0(data)).to.equal(a) + }) + }) + }) + + describe('lastWordIsZero', () => { + context('when the last word is zero', () => { + const data = ethers.concat([AbiCoder.defaultAbiCoder().encode(['uint256'], [1n]), ethers.ZeroHash]) + + it('returns true', async () => { + expect(await library.lastWordIsZero(data)).to.equal(true) + }) + }) + + context('when the last word is not zero', () => { + const data = ethers.concat([ + AbiCoder.defaultAbiCoder().encode(['uint256'], [1n]), + AbiCoder.defaultAbiCoder().encode(['uint256'], [2n]), + ]) + + it('returns false', async () => { + expect(await library.lastWordIsZero(data)).to.equal(false) + }) + }) + }) + + describe('slice', () => { + const data = '0x00112233445566778899aabbccddeeff' + + context('when slicing the full range', () => { + it('returns the same bytes', async () => { + const out = await library.slice(data, 0, (data.length - 2) / 2) + expect(out).to.equal(data) + }) + }) + + context('when slicing a middle range', () => { + it('returns the expected bytes', async () => { + const out = await library.slice(data, 2, 6) + expect(out).to.equal('0x22334455') + }) + }) + + context('when slicing an empty range', () => { + it('returns empty bytes', async () => { + const out = await library.slice(data, 5, 5) + expect(out).to.equal('0x') + }) + }) + + context('when end is smaller than start', () => { + it('reverts', async () => { + await expect(library.slice(data, 6, 2)).to.be.revertedWithCustomError(library, 'BytesLibSliceOutOfBounds') + }) + }) + + context('when end is out of bounds', () => { + it('reverts', async () => { + const len = (data.length - 2) / 2 + await expect(library.slice(data, 0, len + 1)).to.be.revertedWithCustomError(library, 'BytesLibSliceOutOfBounds') + }) + }) + + context('when start equals length and end equals length', () => { + it('returns empty bytes', async () => { + const len = (data.length - 2) / 2 + const out = await library.slice(data, len, len) + expect(out).to.equal('0x') + }) + }) + }) + + describe('sliceFrom', () => { + const data = '0x00112233445566778899aabbccddeeff' + + context('when start is 0', () => { + it('returns the same bytes', async () => { + const out = await library.sliceFrom(data, 0) + expect(out).to.equal(data) + }) + }) + + context('when start is in the middle', () => { + it('returns the expected bytes', async () => { + const out = await library.sliceFrom(data, 4) + expect(out).to.equal('0x445566778899aabbccddeeff') + }) + }) + + context('when start equals length', () => { + it('returns empty bytes', async () => { + const len = (data.length - 2) / 2 + const out = await library.sliceFrom(data, len) + expect(out).to.equal('0x') + }) + }) + + context('when start is out of bounds', () => { + it('reverts', async () => { + const len = (data.length - 2) / 2 + await expect(library.sliceFrom(data, len + 1)).to.be.revertedWithCustomError( + library, + 'BytesLibSliceOutOfBounds' + ) + }) + }) + }) +}) From d3e4d3078c66538ae6075329eeab28b42d54c185 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Tue, 20 Jan 2026 09:32:49 -0300 Subject: [PATCH 03/21] evm: add new dynamic call intent type --- packages/evm/contracts/Intents.sol | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index 00afb1a..5f1d262 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -2,16 +2,20 @@ pragma solidity ^0.8.20; +import './dynamic-calls/DynamicCallTypes.sol'; + /** * @dev Enum representing the type of intent operation. * - Swap: Swap tokens between chains or tokens. * - Transfer: Transfer tokens to one or more recipients. * - Call: Execute arbitrary contract calls. + * - DynamicCall: Execute arbitrary dynamic contract calls. */ enum OpType { Swap, Transfer, - Call + Call, + DynamicCall } /** @@ -162,6 +166,16 @@ struct CallData { uint256 value; } +/** + * @dev Represents a generic dynamic call intent consisting of one or more dynamic contract calls. + * @param chainId Chain ID where the calls should be executed. + * @param calls List of low-level dynamic contract calls to be executed. + */ +struct DynamicCallIntent { + uint256 chainId; + DynamicCall[] calls; +} + /** * @dev Generic proposal structure representing a solver’s response to an intent. * @param deadline Timestamp until when the proposal is valid. From 4a7369955f61e3fa9ae7d605795798a7441a45df Mon Sep 17 00:00:00 2001 From: lgalende Date: Tue, 7 Apr 2026 14:28:43 -0300 Subject: [PATCH 04/21] chore: rename file and struct --- packages/evm/contracts/Intents.sol | 4 ++-- ...DynamicCallResolver.test.ts => DynamicCallEncoder.test.ts} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/evm/test/dynamic-calls/{DynamicCallResolver.test.ts => DynamicCallEncoder.test.ts} (100%) diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index b2a7205..c85bf37 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -167,11 +167,11 @@ struct CallData { } /** - * @dev Represents a generic dynamic call intent consisting of one or more dynamic contract calls. + * @dev Represents a generic dynamic call operation consisting of one or more dynamic contract calls. * @param chainId Chain ID where the calls should be executed. * @param calls List of low-level dynamic contract calls to be executed. */ -struct DynamicCallIntent { +struct DynamicCallOperation { uint256 chainId; DynamicCall[] calls; } diff --git a/packages/evm/test/dynamic-calls/DynamicCallResolver.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts similarity index 100% rename from packages/evm/test/dynamic-calls/DynamicCallResolver.test.ts rename to packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts From da11533b87beb5cdadcf060d9681d87fd26073c9 Mon Sep 17 00:00:00 2001 From: lgalende Date: Tue, 7 Apr 2026 14:53:42 -0300 Subject: [PATCH 05/21] evm: unwrap delegatecall return in SmartAccountsHandlerHelpers --- .../SmartAccountsHandlerHelpers.sol | 10 ++-- .../SmartAccountsHandlerHelpersMock.sol | 17 +++++++ .../SmartAccountsHandlerHelpers.test.ts | 49 +++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 packages/evm/contracts/test/smart-accounts/SmartAccountsHandlerHelpersMock.sol create mode 100644 packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts diff --git a/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol b/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol index 5e3a249..a17bde2 100644 --- a/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol +++ b/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol @@ -44,10 +44,10 @@ library SmartAccountsHandlerHelpers { internal returns (bytes memory) { - return - Address.functionDelegateCall( - handler, - abi.encodeWithSelector(ISmartAccountsHandler.call.selector, account, target, data, value) - ); + bytes memory result = Address.functionDelegateCall( + handler, + abi.encodeWithSelector(ISmartAccountsHandler.call.selector, account, target, data, value) + ); + return abi.decode(result, (bytes)); } } diff --git a/packages/evm/contracts/test/smart-accounts/SmartAccountsHandlerHelpersMock.sol b/packages/evm/contracts/test/smart-accounts/SmartAccountsHandlerHelpersMock.sol new file mode 100644 index 0000000..c351c79 --- /dev/null +++ b/packages/evm/contracts/test/smart-accounts/SmartAccountsHandlerHelpersMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import '../../smart-accounts/SmartAccountsHandlerHelpers.sol'; + +contract SmartAccountsHandlerHelpersMock { + using SmartAccountsHandlerHelpers for address; + + function call(address handler, address account, address target, bytes memory data, uint256 value) + external + returns (bytes memory) + { + // solhint-disable-next-line avoid-low-level-calls + return handler.call(account, target, data, value); + } +} diff --git a/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts b/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts new file mode 100644 index 0000000..f6213e5 --- /dev/null +++ b/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts @@ -0,0 +1,49 @@ +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/types' +import { expect } from 'chai' +import { AbiCoder } from 'ethers' +import { network } from 'hardhat' + +import { + SmartAccountContract, + SmartAccountsHandler, + SmartAccountsHandlerHelpersMock, + StaticCallMock, +} from '../../types/ethers-contracts/index.js' + +const { ethers } = await network.connect() + +describe('SmartAccountsHandlerHelpers', () => { + let helper: SmartAccountsHandlerHelpersMock + let handler: SmartAccountsHandler + let smartAccount: SmartAccountContract + let target: StaticCallMock + let owner: HardhatEthersSigner + + beforeEach('setup signers', async () => { + // eslint-disable-next-line prettier/prettier + [, owner] = await ethers.getSigners() + }) + + beforeEach('deploy contracts', async () => { + // eslint-disable-next-line no-secrets/no-secrets + helper = await ethers.deployContract('SmartAccountsHandlerHelpersMock') + handler = await ethers.deployContract('SmartAccountsHandler') + smartAccount = await ethers.deployContract('SmartAccountContract', [helper, owner]) + target = await ethers.deployContract('StaticCallMock') + }) + + describe('call', () => { + it('returns the expected bytes', async () => { + const value = 11n + const data = target.interface.encodeFunctionData('returnUint', [value]) + + const result = await helper.call.staticCall(handler, smartAccount, target, data, 0) + + const expected = target.interface.encodeFunctionResult('returnUint', [value]) + expect(result).to.equal(expected) + + const [decoded] = AbiCoder.defaultAbiCoder().decode(['uint256'], result) + expect(decoded).to.equal(value) + }) + }) +}) From d8de41afd5ba6aca91c9d9a3737da84dc36c2223 Mon Sep 17 00:00:00 2001 From: lgalende Date: Tue, 7 Apr 2026 16:30:00 -0300 Subject: [PATCH 06/21] evm: add array support to bytes helpers --- .../contracts/test/utils/BytesHelpersMock.sol | 5 ++ packages/evm/contracts/utils/BytesHelpers.sol | 18 ++++++ packages/evm/test/utils/BytesHelpers.test.ts | 55 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/evm/contracts/test/utils/BytesHelpersMock.sol b/packages/evm/contracts/test/utils/BytesHelpersMock.sol index 5ff08b3..c54ce85 100644 --- a/packages/evm/contracts/test/utils/BytesHelpersMock.sol +++ b/packages/evm/contracts/test/utils/BytesHelpersMock.sol @@ -18,6 +18,7 @@ import '../../utils/BytesHelpers.sol'; contract BytesHelpersMock { using BytesHelpers for bytes; + using BytesHelpers for bytes[]; function readWord0(bytes memory data) external pure returns (uint256) { return data.readWord0(); @@ -34,4 +35,8 @@ contract BytesHelpersMock { function sliceFrom(bytes memory data, uint256 start) external pure returns (bytes memory) { return data.sliceFrom(start); } + + function sliceArray(bytes[] memory data, uint256 start, uint256 end) external pure returns (bytes[] memory) { + return data.slice(start, end); + } } diff --git a/packages/evm/contracts/utils/BytesHelpers.sol b/packages/evm/contracts/utils/BytesHelpers.sol index 6c8d1d1..70fdd26 100644 --- a/packages/evm/contracts/utils/BytesHelpers.sol +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -81,4 +81,22 @@ library BytesHelpers { function sliceFrom(bytes memory data, uint256 start) internal pure returns (bytes memory out) { return slice(data, start, data.length); } + + /** + * @dev Returns a slice of a bytes array from `start` (inclusive) to `end` (exclusive) + * @param data Bytes array to slice + * @param start Starting item index (inclusive) + * @param end Ending item index (exclusive) + */ + function slice(bytes[] memory data, uint256 start, uint256 end) internal pure returns (bytes[] memory out) { + if (end < start) revert BytesLibSliceOutOfBounds(); + if (end > data.length) revert BytesLibSliceOutOfBounds(); + + uint256 len = end - start; + out = new bytes[](len); + for (uint256 i = 0; i < len; i++) { + bytes memory item = data[start + i]; + out[i] = slice(item, 0, item.length); + } + } } diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts index 2a91016..5959a4d 100644 --- a/packages/evm/test/utils/BytesHelpers.test.ts +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -1,13 +1,15 @@ import { expect } from 'chai' -import { AbiCoder, Contract } from 'ethers' +import { AbiCoder } from 'ethers' import { network } from 'hardhat' +import { BytesHelpersMock } from '../../types/ethers-contracts/index.js' + const { ethers } = await network.connect() /* eslint-disable no-secrets/no-secrets */ describe('BytesHelpers', () => { - let library: Contract + let library: BytesHelpersMock beforeEach('deploy helpers mock', async () => { library = await ethers.deployContract('BytesHelpersMock') @@ -55,7 +57,7 @@ describe('BytesHelpers', () => { }) }) - describe('slice', () => { + describe('slice(bytes)', () => { const data = '0x00112233445566778899aabbccddeeff' context('when slicing the full range', () => { @@ -136,4 +138,51 @@ describe('BytesHelpers', () => { }) }) }) + + describe('slice(bytes[])', () => { + const data = ['0x11', '0x2233', '0x445566'] + + context('when slicing a prefix', () => { + it('returns the expected items', async () => { + const out = await library.sliceArray(data, 0, 2) + expect(out).to.deep.equal(['0x11', '0x2233']) + }) + }) + + context('when slicing a middle range', () => { + it('returns the expected items', async () => { + const out = await library.sliceArray(data, 1, 3) + expect(out).to.deep.equal(['0x2233', '0x445566']) + }) + }) + + context('when slicing the full array', () => { + it('returns all items', async () => { + const out = await library.sliceArray(data, 0, data.length) + expect(out).to.deep.equal(data) + }) + }) + + context('when slicing an empty range', () => { + it('returns an empty array', async () => { + const out = await library.sliceArray(data, 1, 1) + expect(out).to.deep.equal([]) + }) + }) + + context('when end is smaller than start', () => { + it('reverts', async () => { + await expect(library.sliceArray(data, 2, 1)).to.be.revertedWithCustomError(library, 'BytesLibSliceOutOfBounds') + }) + }) + + context('when end is out of bounds', () => { + it('reverts', async () => { + await expect(library.sliceArray(data, 0, data.length + 1)).to.be.revertedWithCustomError( + library, + 'BytesLibSliceOutOfBounds' + ) + }) + }) + }) }) From 0664c8312fac84c8f7aaa6af50afac37f3356059 Mon Sep 17 00:00:00 2001 From: lgalende Date: Tue, 7 Apr 2026 22:06:17 -0300 Subject: [PATCH 07/21] evm: add dynamic call encoder interface --- .../dynamic-calls/DynamicCallEncoder.sol | 44 +++-------- .../interfaces/IDynamicCallEncoder.sol | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 34 deletions(-) create mode 100644 packages/evm/contracts/interfaces/IDynamicCallEncoder.sol diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol index 4967307..7628285 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -15,6 +15,7 @@ pragma solidity ^0.8.20; import './DynamicCallTypes.sol'; +import '../interfaces/IDynamicCallEncoder.sol'; import '../utils/BytesHelpers.sol'; /** @@ -29,39 +30,9 @@ import '../utils/BytesHelpers.sol'; * The encoder follows standard ABI encoding rules, reconstructing * the calldata heads and tails dynamically based on argument types. */ -contract DynamicCallEncoder { +contract DynamicCallEncoder is IDynamicCallEncoder { using BytesHelpers for bytes; - /// @dev Thrown when an argument is not word-aligned - error DynamicCallEncoderBadLength(); - - /// @dev Thrown when a dynamic value resolves to empty data - error DynamicCallEncoderEmptyDynamic(); - - /// @dev Thrown when a static literal has an invalid size prefix - error DynamicCallEncoderBadStaticSize(); - - /// @dev Thrown when a static literal does not end with a zero word - error DynamicCallEncoderBadStaticTrailer(); - - /// @dev Thrown when a static literal is too short to be valid - error DynamicCallEncoderTooShortStatic(); - - /// @dev Thrown when a variable reference is not exactly one word - error DynamicCallEncoderVariableRefBadLength(); - - /// @dev Thrown when a variable index is outside the variables array - error DynamicCallEncoderVariableOutOfBounds(); - - /// @dev Thrown when a variable value is too short to be interpreted - error DynamicCallEncoderVariableTooShort(); - - /// @dev Thrown when a static call argument cannot be decoded - error DynamicCallEncoderStaticCallBadSpec(); - - /// @dev Thrown when a staticcall execution fails - error DynamicCallEncoderStaticCallFailed(address target); - /** * @dev Internal representation of a fully-encoded argument * @param data ABI-encoded argument payload: @@ -78,12 +49,17 @@ contract DynamicCallEncoder { /** * @dev Encodes a dynamic call into calldata - * @param call_ Dynamic call specification + * @param dynamicCall Dynamic call specification * @param variables List of resolved variable values * @return data Fully ABI-encoded calldata */ - function encode(DynamicCall memory call_, bytes[] memory variables) external view returns (bytes memory data) { - data = _buildCalldata(call_.selector, call_.arguments, variables); + function encode(DynamicCall memory dynamicCall, bytes[] memory variables) + external + view + override + returns (bytes memory data) + { + data = _buildCalldata(dynamicCall.selector, dynamicCall.arguments, variables); } /** diff --git a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol new file mode 100644 index 0000000..3248856 --- /dev/null +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +import '../dynamic-calls/DynamicCallTypes.sol'; + +interface IDynamicCallEncoder { + /** + * @dev The argument is not word-aligned + */ + error DynamicCallEncoderBadLength(); + + /** + * @dev The dynamic value resolves to empty data + */ + error DynamicCallEncoderEmptyDynamic(); + + /** + * @dev The static literal has an invalid size prefix + */ + error DynamicCallEncoderBadStaticSize(); + + /** + * @dev The static literal does not end with a zero word + */ + error DynamicCallEncoderBadStaticTrailer(); + + /** + * @dev The static literal is too short to be valid + */ + error DynamicCallEncoderTooShortStatic(); + + /** + * @dev The variable reference is not exactly one word + */ + error DynamicCallEncoderVariableRefBadLength(); + + /** + * @dev The variable index is outside the variables array + */ + error DynamicCallEncoderVariableOutOfBounds(); + + /** + * @dev The variable value is too short to be interpreted + */ + error DynamicCallEncoderVariableTooShort(); + + /** + * @dev The static call argument cannot be decoded + */ + error DynamicCallEncoderStaticCallBadSpec(); + + /** + * @dev The staticcall execution failed + */ + error DynamicCallEncoderStaticCallFailed(address target); + + /** + * @dev Encodes a dynamic call into calldata. + * @param dynamicCall Dynamic call specification. + * @param variables Resolved outputs from previous operations. + */ + function encode(DynamicCall memory dynamicCall, bytes[] memory variables) external view returns (bytes memory); +} From 862c2d86dc9f1cb05ca52be363cb636407009591 Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 8 Apr 2026 02:18:39 -0300 Subject: [PATCH 08/21] evm: add dynamic call to settler --- packages/evm/contracts/Intents.sol | 4 +- packages/evm/contracts/Settler.sol | 110 +++++- .../evm/contracts/interfaces/ISettler.sol | 5 + .../safeguards/OperationsValidator.sol | 1 + packages/evm/test/Settler.test.ts | 348 ++++++++++++++++++ .../evm/test/helpers/intents/dynamic-call.ts | 67 ++++ packages/evm/test/helpers/intents/index.ts | 1 + .../evm/test/helpers/proposal/dynamic-call.ts | 5 + packages/evm/test/helpers/proposal/index.ts | 1 + 9 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 packages/evm/test/helpers/intents/dynamic-call.ts create mode 100644 packages/evm/test/helpers/proposal/dynamic-call.ts diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index c85bf37..ac44c97 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -169,11 +169,11 @@ struct CallData { /** * @dev Represents a generic dynamic call operation consisting of one or more dynamic contract calls. * @param chainId Chain ID where the calls should be executed. - * @param calls List of low-level dynamic contract calls to be executed. + * @param calls List of ABI-encoded low-level dynamic contract calls to be executed. */ struct DynamicCallOperation { uint256 chainId; - DynamicCall[] calls; + bytes[] calls; } /** diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index f7dd29a..a400802 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -23,10 +23,13 @@ import '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; import '@openzeppelin/contracts/utils/introspection/ERC165Checker.sol'; import './Intents.sol'; +import './dynamic-calls/DynamicCallEncoder.sol'; import './interfaces/IController.sol'; +import './interfaces/IDynamicCallEncoder.sol'; import './interfaces/IOperationsValidator.sol'; import './interfaces/IExecutor.sol'; import './interfaces/ISettler.sol'; +import './utils/BytesHelpers.sol'; import './utils/Denominations.sol'; import './utils/ERC20Helpers.sol'; import './smart-accounts/SmartAccountsHandler.sol'; @@ -38,6 +41,7 @@ import './smart-accounts/SmartAccountsHandlerHelpers.sol'; */ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { using SafeERC20 for IERC20; + using BytesHelpers for bytes[]; using IntentsHelpers for Intent; using IntentsHelpers for Proposal; using IntentsHelpers for Validation; @@ -53,6 +57,9 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { // Operations validator reference address public override operationsValidator; + // Dynamic call encoder reference + address public dynamicCallEncoder; + // List of block numbers at which an intent was executed mapping (bytes32 => uint256) public override getIntentBlock; @@ -76,6 +83,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { constructor(address _controller, address _owner) Ownable(_owner) EIP712('Mimic Protocol Settler', '1') { controller = _controller; smartAccountsHandler = address(new SmartAccountsHandler()); + dynamicCallEncoder = address(new DynamicCallEncoder()); } /** @@ -199,25 +207,49 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _validateIntent(intent, proposal, signature, simulated); getIntentBlock[intent.hash()] = block.number; + bytes[] memory outputs = new bytes[](intent.operations.length); for (uint256 i = 0; i < intent.operations.length; i++) { - uint8 opType = intent.operations[i].opType; - if (opType == uint8(OpType.Swap) || opType == uint8(OpType.CrossChainSwap)) { - _executeSwap(intent, proposal, i); - } else if (opType == uint8(OpType.Transfer)) _executeTransfer(intent, proposal, i); - else if (opType == uint8(OpType.Call)) _executeCall(intent, proposal, i); - else revert SettlerUnknownOperationType(uint8(opType)); + // TODO: support multiple outputs + outputs[i] = _executeOperation(intent, proposal, i, outputs); } + _payFees(intent, proposal); emit ProposalExecuted(proposal.hash(intent, _msgSender())); } + /** + * @dev Executes proposal to fulfill an operation + * @param intent Intent being fulfilled + * @param proposal Proposal being executed + * @param index Position where the operation and its corresponding proposal data are located + * @param outputs List of operations outputs + */ + function _executeOperation(Intent memory intent, Proposal memory proposal, uint256 index, bytes[] memory outputs) + internal + returns (bytes memory) + { + uint8 opType = intent.operations[index].opType; + if (opType == uint8(OpType.Swap) || opType == uint8(OpType.CrossChainSwap)) { + return _executeSwap(intent, proposal, index); + } + if (opType == uint8(OpType.Transfer)) return _executeTransfer(intent, proposal, index); + if (opType == uint8(OpType.Call)) return _executeCall(intent, proposal, index); + if (opType == uint8(OpType.DynamicCall)) { + return _executeDynamicCall(intent, proposal, index, outputs.slice(0, index)); + } + revert SettlerUnknownOperationType(opType); + } + /** * @dev Validates and executes a proposal to fulfill a swap operation * @param intent Intent that contains swap operation to be fulfilled * @param proposal Proposal with swap data to be executed * @param index Position where the swap proposal data and operation are located */ - function _executeSwap(Intent memory intent, Proposal memory proposal, uint256 index) internal { + function _executeSwap(Intent memory intent, Proposal memory proposal, uint256 index) + internal + returns (bytes memory) + { Operation memory operation = intent.operations[index]; SwapOperation memory swapOperation = abi.decode(operation.data, (SwapOperation)); SwapProposal memory swapProposal = abi.decode(proposal.datas[index], (SwapProposal)); @@ -237,8 +269,8 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { uint256[] memory preBalancesOut = _getTokensOutBalance(swapOperation); IExecutor(swapProposal.executor).execute(intent, proposal, index); + uint256[] memory outputs = new uint256[](swapOperation.tokensOut.length); if (swapOperation.destinationChain == block.chainid) { - uint256[] memory outputs = new uint256[](swapOperation.tokensOut.length); for (uint256 i = 0; i < swapOperation.tokensOut.length; i++) { TokenOut memory tokenOut = swapOperation.tokensOut[i]; uint256 postBalanceOut = ERC20Helpers.balanceOf(tokenOut.token, address(this)); @@ -254,6 +286,8 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); } + + return outputs.length > 0 ? abi.encode(outputs[0]) : new bytes(0); } /** @@ -262,7 +296,10 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param proposal Transfer proposal to be executed * @param index Position where the trasnfer proposal data and operation are located */ - function _executeTransfer(Intent memory intent, Proposal memory proposal, uint256 index) internal { + function _executeTransfer(Intent memory intent, Proposal memory proposal, uint256 index) + internal + returns (bytes memory) + { Operation memory operation = intent.operations[index]; TransferOperation memory transferOperation = abi.decode(operation.data, (TransferOperation)); _validateTransferOperation(transferOperation, proposal.datas[index]); @@ -273,7 +310,9 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _transferFrom(transfer.token, operation.user, transfer.recipient, transfer.amount, isSmartAccount); } - _emitOperationEvents(operation, proposal, intent.hash(), index, new bytes(0)); + bytes memory output = new bytes(0); + _emitOperationEvents(operation, proposal, intent.hash(), index, output); + return output; } /** @@ -282,7 +321,10 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param proposal Call proposal to be executed * @param index Position where the call proposal data and operation are located */ - function _executeCall(Intent memory intent, Proposal memory proposal, uint256 index) internal { + function _executeCall(Intent memory intent, Proposal memory proposal, uint256 index) + internal + returns (bytes memory) + { Operation memory operation = intent.operations[index]; CallOperation memory callOperation = abi.decode(operation.data, (CallOperation)); _validateCallOperation(callOperation, proposal.datas[index], operation.user); @@ -295,6 +337,36 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { } _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); + return outputs.length > 0 ? outputs[0] : new bytes(0); + } + + /** + * @dev Validates and executes a proposal to fulfill a dynamic call operation + * @param intent Intent that contains dynamic call operation to be fulfilled + * @param proposal Dynamic call proposal to be executed + * @param index Position where the dynamic call proposal data and operation are located + * @param variables List of previous operations outputs + */ + function _executeDynamicCall( + Intent memory intent, + Proposal memory proposal, + uint256 index, + bytes[] memory variables + ) internal returns (bytes memory) { + Operation memory operation = intent.operations[index]; + DynamicCallOperation memory dynamicCallOperation = abi.decode(operation.data, (DynamicCallOperation)); + _validateDynamicCallOperation(dynamicCallOperation, proposal.datas[index], operation.user); + + bytes[] memory outputs = new bytes[](dynamicCallOperation.calls.length); + for (uint256 i = 0; i < dynamicCallOperation.calls.length; i++) { + DynamicCall memory dynamicCall = abi.decode(dynamicCallOperation.calls[i], (DynamicCall)); + bytes memory data = IDynamicCallEncoder(dynamicCallEncoder).encode(dynamicCall, variables); + // solhint-disable-next-line avoid-low-level-calls + outputs[i] = smartAccountsHandler.call(operation.user, dynamicCall.target, data, dynamicCall.value); + } + + _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); + return outputs.length > 0 ? outputs[0] : new bytes(0); } /** @@ -427,6 +499,22 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { if (!smartAccountsHandler.isSmartAccount(user)) revert SettlerUserNotSmartAccount(user); } + /** + * @dev Validates a dynamic call operation and its corresponding proposal + * @param operation Dynamic call operation to be fulfilled + * @param proposalData data of the proposal + * @param user The originator of the operation + */ + function _validateDynamicCallOperation( + DynamicCallOperation memory operation, + bytes memory proposalData, + address user + ) internal view { + if (operation.chainId != block.chainid) revert SettlerInvalidChain(block.chainid); + if (proposalData.length > 0) revert SettlerProposalDataNotEmpty(); + if (!smartAccountsHandler.isSmartAccount(user)) revert SettlerUserNotSmartAccount(user); + } + /** * @dev Validates a cross-chain swap operation and its corresponding proposal * @param operation Swap operation to be fulfilled diff --git a/packages/evm/contracts/interfaces/ISettler.sol b/packages/evm/contracts/interfaces/ISettler.sol index efc46de..a876e92 100644 --- a/packages/evm/contracts/interfaces/ISettler.sol +++ b/packages/evm/contracts/interfaces/ISettler.sol @@ -214,6 +214,11 @@ interface ISettler { */ function operationsValidator() external view returns (address); + /** + * @dev Tells the reference to the dynamic call encoder + */ + function dynamicCallEncoder() external view returns (address); + /** * @dev Tells the block at which an intent was executed. Returns 0 if unexecuted. * @param hash Hash of the intent being queried diff --git a/packages/evm/contracts/safeguards/OperationsValidator.sol b/packages/evm/contracts/safeguards/OperationsValidator.sol index 342095f..fb3ca10 100644 --- a/packages/evm/contracts/safeguards/OperationsValidator.sol +++ b/packages/evm/contracts/safeguards/OperationsValidator.sol @@ -139,6 +139,7 @@ contract OperationsValidator is } if (operation.opType == uint8(OpType.Transfer)) return _isTransferOperationValid(operation, safeguard); if (operation.opType == uint8(OpType.Call)) return _isCallOperationValid(operation, safeguard); + if (operation.opType == uint8(OpType.DynamicCall)) return true; // TODO: implement revert OperationsValidatorUnknownOperationType(uint8(operation.opType)); } diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 2c1fd3b..9dd73d9 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -38,6 +38,9 @@ import { createCallProposal, createCrossChainSwapIntent, createCrossChainSwapOperation, + createDynamicCallIntent, + createDynamicCallOperation, + createDynamicCallProposal, createIntent, createProposal, createSwapIntent, @@ -47,17 +50,21 @@ import { createTransferOperation, createTransferProposal, currentTimestamp, + DynamicCallOperation, hashIntent, hashProposal, Intent, + literal, Proposal, signProposal, + staticCall, SwapOperation, SwapProposal, toAddress, toArray, TransferOperation, TransferProposal, + variable, } from './helpers' import { addValidations } from './helpers/validations' @@ -96,6 +103,10 @@ describe('Settler', () => { it('has a smart accounts handler', async () => { expect(await settler.smartAccountsHandler()).to.not.be.equal(ZERO_ADDRESS) }) + + it.skip('has a dynamic call decoder', async () => { + expect(await settler.dynamicCallEncoder()).to.not.be.equal(ZERO_ADDRESS) + }) }) describe('ownable', () => { @@ -1055,6 +1066,148 @@ describe('Settler', () => { }) }) + context('for dynamic call operations', () => { + const dynamicCallOperationParams: Partial = {} + const dynamicCallProposalParams: Partial = {} + let token: TokenMock + + beforeEach('set token', async () => { + token = await ethers.deployContract('TokenMock', ['TKN', 18]) + }) + + beforeEach('set intent params', async () => { + const target = await ethers.deployContract('StaticCallMock') + dynamicCallOperationParams.calls = [ + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [literal(['uint256'], [11n])], + }, + ] + }) + + const itReverts = (reason: string) => { + it('reverts', async () => { + const intent = createDynamicCallIntent( + intentParams, + dynamicCallOperationParams + ) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createDynamicCallProposal({ + ...proposalParams, + ...dynamicCallProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + await expect( + settler.execute(intent, proposal, signature) + ).to.be.revertedWithCustomError(settler, reason) + }) + } + + context('when the chain is the current chain', () => { + beforeEach('set chain', () => { + dynamicCallOperationParams.chainId = 31337 + }) + + context('when the proposal has some data', () => { + beforeEach('set proposal data', () => { + dynamicCallProposalParams.datas = ['0xab'] + }) + + itReverts('SettlerProposalDataNotEmpty') + }) + + context('when the user is a smart account', () => { + beforeEach('set proposal data', () => { + dynamicCallProposalParams.datas = ['0x'] + }) + + beforeEach('set intent user', async () => { + const smartAccountUser = await ethers.deployContract( + 'SmartAccountContract', + [settler, owner] + ) + intentParams.feePayer = smartAccountUser + dynamicCallOperationParams.user = smartAccountUser + await feeToken.mint(intentParams.feePayer, feeAmount) + }) + + it('executes successfully', async () => { + const intent = createDynamicCallIntent( + intentParams, + dynamicCallOperationParams + ) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createDynamicCallProposal({ + ...proposalParams, + ...dynamicCallProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + const tx = await settler.execute(intent, proposal, signature) + + const settlerEvents = await settler.queryFilter( + settler.filters.ProposalExecuted(), + tx.blockNumber + ) + expect(settlerEvents).to.have.lengthOf(1) + + const proposalHash = await settler.getProposalHash( + proposal, + intent, + solver + ) + expect(settlerEvents[0].args.proposal).to.be.equal(proposalHash) + }) + }) + + context('when the user is not a smart account', () => { + beforeEach('set proposal data', () => { + dynamicCallProposalParams.datas = ['0x'] + }) + + context('when the user is an EOA', () => { + beforeEach('set intent user', async () => { + intentParams.feePayer = other + dynamicCallOperationParams.user = other + }) + + itReverts('SettlerUserNotSmartAccount') + }) + + context('when the user is another contract', () => { + beforeEach('set intent user', async () => { + intentParams.feePayer = token + dynamicCallOperationParams.user = token + }) + + itReverts('SettlerUserNotSmartAccount') + }) + }) + }) + + context('when the chain is not the current chain', () => { + beforeEach('set chain', () => { + dynamicCallOperationParams.chainId = 1 + }) + + itReverts('SettlerInvalidChain') + }) + }) + context('for cross chain swap operations', () => { const swapOperationParams: Partial = {} const swapProposalParams: Partial = {} @@ -2873,6 +3026,201 @@ describe('Settler', () => { }) }) + context('dynamic call', () => { + let user: SmartAccount + let target: Account + let feeToken: TokenMock + let proposal: Proposal + + const argument = randomEvmAddress() + const feeAmount = fp(0.01) + const eventTopic = randomHex(32) + const eventData = randomHex(120) + + beforeEach('deploy contracts', async () => { + user = await ethers.deployContract('SmartAccountContract', [settler, owner]) + target = await ethers.deployContract('StaticCallMock') + feeToken = await ethers.deployContract('TokenMock', ['WETH', 18]) + }) + + beforeEach('mint tokens', async () => { + await feeToken.mint(user, feeAmount) + }) + + beforeEach('create intent', async () => { + intent = createDynamicCallIntent( + { + settler, + feePayer: user, + maxFees: [{ token: feeToken, amount: feeAmount }], + }, + { + user, + calls: [ + { + target, + selector: target.interface.getFunction('returnAddress')!.selector, + arguments: [literal(['address'], [argument])], + }, + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [staticCall(feeToken.target, feeToken.interface.getFunction('decimals')!.selector, [])], + }, + ], + events: [{ topic: eventTopic, data: eventData }], + } + ) + }) + + beforeEach('create proposal', () => { + proposal = createDynamicCallProposal({ fees: [feeAmount] }) + }) + + it('executes the intent', async () => { + const preUserBalance = await balanceOf(feeToken, user) + const preSolverBalance = await balanceOf(feeToken, solver) + + const signature = await signProposal(settler, intent, solver, proposal, admin) + await settler.execute(intent, proposal, signature) + + const postUserBalance = await balanceOf(feeToken, user) + expect(preUserBalance - postUserBalance).to.be.eq(feeAmount) + + const postSolverBalance = await balanceOf(feeToken, solver) + expect(postSolverBalance - preSolverBalance).to.be.eq(feeAmount) + }) + + it('logs the intent events correctly', async () => { + const signature = await signProposal(settler, intent, solver, proposal, admin) + const tx = await settler.execute(intent, proposal, signature) + + const events = await settler.queryFilter(settler.filters.OperationExecuted(), tx.blockNumber) + expect(events).to.have.lengthOf(1) + + expect(events[0].args.user).to.be.equal(intent.operations[0].user) + expect(events[0].args.topic).to.be.equal(eventTopic) + expect(events[0].args.opType).to.be.equal(4) + expect(events[0].args.intentHash).to.be.equal(hashIntent(intent)) + expect(events[0].args.data).to.be.equal(eventData) + + const [outputs] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[0].args.output) + expect(outputs).to.have.lengthOf(2) + + const [decodedA] = AbiCoder.defaultAbiCoder().decode(['address'], outputs[0]) + expect(decodedA.toLowerCase()).to.be.equal(argument) + + const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[1]) + expect(decodedB).to.be.equal(18) + }) + }) + + context('swap + dynamic call', () => { + let smartAccount: SmartAccount + let tokenIn: TokenMock + let tokenOutA: TokenMock, tokenOutB: TokenMock + let executor: TransferExecutorMock + let target: Account + let proposal: Proposal + + const chainId = 31337 + const swapAmountIn = fp(1) + const swapAmountOutA = BigInt(2900 * 1e6) + const swapAmountOutB = BigInt(7 * 1e18) + const eventTopic = randomHex(32) + const eventData = randomHex(120) + + beforeEach('deploy contracts', async () => { + smartAccount = await ethers.deployContract('SmartAccountContract', [settler, owner]) + tokenIn = await ethers.deployContract('TokenMock', ['WETH', 18]) + tokenOutA = await ethers.deployContract('TokenMock', ['USDC', 6]) + tokenOutB = await ethers.deployContract('TokenMock', ['DAI', 18]) + executor = await ethers.deployContract('TransferExecutorMock') + target = await ethers.deployContract('StaticCallMock') + }) + + beforeEach('mint and approve tokens', async () => { + await tokenIn.mint(user, swapAmountIn) + await tokenIn.connect(user).approve(settler, swapAmountIn) + await tokenOutA.mint(executor, swapAmountOutA) + await tokenOutB.mint(executor, swapAmountOutB) + }) + + beforeEach('create intent', async () => { + const swapOperation = createSwapOperation({ + user, + sourceChain: chainId, + destinationChain: chainId, + tokensIn: { token: tokenIn, amount: swapAmountIn }, + tokensOut: [ + { token: tokenOutA, minAmount: swapAmountOutA, recipient: other }, + { token: tokenOutB, minAmount: swapAmountOutB, recipient: other }, + ], + }) + + const dynamicCallOperation = createDynamicCallOperation({ + user: smartAccount, + chainId, + calls: [ + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [variable(0)], + }, + ], + events: [{ topic: eventTopic, data: eventData }], + }) + + intent = createIntent({ + settler, + feePayer: user, + maxFees: [], + operations: [swapOperation, dynamicCallOperation], + }) + }) + + beforeEach('create proposal', () => { + const executorData = AbiCoder.defaultAbiCoder().encode( + ['address[]', 'uint256[]'], + [ + [tokenOutA.target, tokenOutB.target], + [swapAmountOutA, swapAmountOutB], + ] + ) + + proposal = createSwapProposal({ + executor, + executorData, + amountsOut: [swapAmountOutA, swapAmountOutB], + }) + proposal.datas = [...proposal.datas, '0x'] + }) + + it('passes the swap output into the dynamic call', async () => { + const signature = await signProposal(settler, intent, solver, proposal, admin) + const tx = await settler.execute(intent, proposal, signature) + + const events = await settler.queryFilter(settler.filters.OperationExecuted(), tx.blockNumber) + expect(events).to.have.lengthOf(1) + + expect(events[0].args.opType).to.be.equal(4) + expect(events[0].args.topic).to.be.equal(eventTopic) + expect(events[0].args.data).to.be.equal(eventData) + + const [outputs] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[0].args.output) + expect(outputs).to.have.lengthOf(1) + + const [decoded] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[0]) + expect(decoded).to.be.equal(swapAmountOutA) + + const callEvents = await smartAccount.queryFilter(smartAccount.filters.Called(), tx.blockNumber) + expect(callEvents).to.have.lengthOf(1) + expect(callEvents[0].args.data).to.be.equal( + target.interface.encodeFunctionData('returnUint', [swapAmountOutA]) + ) + }) + }) + context('one of each', () => { let target: Account, data: string let smartAccount: SmartAccount diff --git a/packages/evm/test/helpers/intents/dynamic-call.ts b/packages/evm/test/helpers/intents/dynamic-call.ts new file mode 100644 index 0000000..ac5e039 --- /dev/null +++ b/packages/evm/test/helpers/intents/dynamic-call.ts @@ -0,0 +1,67 @@ +import { AbiCoder, BigNumberish } from 'ethers' + +import { Account, toAddress } from '../addresses.js' +import { DynamicArg } from '../dynamic-calls.js' +import { createIntent, createOperation, Intent, Operation } from './base.js' + +const DYNAMIC_CALL_OP_TYPE = 4 + +export type DynamicCallOperation = Operation & { + chainId: BigNumberish + calls: DynamicCallData[] +} + +export interface DynamicCallData { + target: Account + value?: BigNumberish + selector: string + arguments: DynamicArg[] +} + +export function createDynamicCallIntent( + intentParams?: Partial, + operationParams?: Partial +): Intent { + const intent = createIntent({ ...intentParams }) + const operation = createDynamicCallOperation({ ...operationParams }) + intent.operations = [operation] + return intent +} + +export function createDynamicCallOperation(params?: Partial): Operation { + const operation = createOperation({ ...params, opType: DYNAMIC_CALL_OP_TYPE }) + const dynamicCallOperation = { ...getDefaults(), ...params, ...operation } as DynamicCallOperation + operation.data = AbiCoder.defaultAbiCoder().encode( + ['tuple(uint256 chainId, bytes[] calls)'], + [toDynamicCallOperationData(dynamicCallOperation)] + ) + return operation +} + +function toDynamicCallOperationData(operation: DynamicCallOperation) { + return { + chainId: operation.chainId.toString(), + calls: operation.calls.map((call) => encodeDynamicCallData(call)), + } +} + +function getDefaults(): Partial { + return { + chainId: 31337, + calls: [], + } +} + +function encodeDynamicCallData(call: DynamicCallData): string { + return AbiCoder.defaultAbiCoder().encode( + ['tuple(address target, uint256 value, bytes4 selector, tuple(uint8 kind, bytes data)[] arguments)'], + [ + { + target: toAddress(call.target), + value: (call.value || 0).toString(), + selector: call.selector, + arguments: call.arguments, + }, + ] + ) +} diff --git a/packages/evm/test/helpers/intents/index.ts b/packages/evm/test/helpers/intents/index.ts index 6f7df96..3e9bc08 100644 --- a/packages/evm/test/helpers/intents/index.ts +++ b/packages/evm/test/helpers/intents/index.ts @@ -1,4 +1,5 @@ export * from './base' export * from './call' +export * from './dynamic-call' export * from './swap' export * from './transfer' diff --git a/packages/evm/test/helpers/proposal/dynamic-call.ts b/packages/evm/test/helpers/proposal/dynamic-call.ts new file mode 100644 index 0000000..9529d59 --- /dev/null +++ b/packages/evm/test/helpers/proposal/dynamic-call.ts @@ -0,0 +1,5 @@ +import { createProposal, Proposal } from './base' + +export function createDynamicCallProposal(params?: Partial): Proposal { + return createProposal(params) +} diff --git a/packages/evm/test/helpers/proposal/index.ts b/packages/evm/test/helpers/proposal/index.ts index 6f7df96..3e9bc08 100644 --- a/packages/evm/test/helpers/proposal/index.ts +++ b/packages/evm/test/helpers/proposal/index.ts @@ -1,4 +1,5 @@ export * from './base' export * from './call' +export * from './dynamic-call' export * from './swap' export * from './transfer' From ed04df67b6f7a86c62e85feb41d733ed5d39257e Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 8 Apr 2026 02:29:48 -0300 Subject: [PATCH 09/21] evm: add dynamic call operations validator --- .../DynamicCallOperationsValidator.sol | 56 ++++++++ .../safeguards/OperationsValidator.sol | 6 +- .../safeguards/OperationsValidator.test.ts | 125 ++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol diff --git a/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol b/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol new file mode 100644 index 0000000..5192880 --- /dev/null +++ b/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import './CallOperationsValidator.sol'; +import './Safeguards.sol'; +import './BaseOperationsValidator.sol'; +import '../Intents.sol'; + +/** + * @title DynamicCallOperationsValidator + * @dev Performs dynamic call operations validations based on call safeguards + */ +contract DynamicCallOperationsValidator is BaseOperationsValidator { + /** + * @dev Tells whether a dynamic call operation is valid for a safeguard + * @param operation Dynamic call operation to be validated + * @param safeguard Safeguard to validate the operation with + */ + function _isDynamicCallOperationValid(Operation memory operation, Safeguard memory safeguard) + internal + pure + returns (bool) + { + DynamicCallOperation memory dynamicCallOperation = abi.decode(operation.data, (DynamicCallOperation)); + if (safeguard.mode == uint8(CallSafeguardMode.Chain)) + return _isChainAllowed(dynamicCallOperation.chainId, safeguard.config); + if (safeguard.mode == uint8(CallSafeguardMode.Target)) + return _areDynamicCallTargetsValid(dynamicCallOperation.calls, safeguard.config); + if (safeguard.mode == uint8(CallSafeguardMode.Selector)) + return _areDynamicCallSelectorsValid(dynamicCallOperation.calls, safeguard.config); + revert OperationsValidatorInvalidSafeguardMode(safeguard.mode); + } + + /** + * @dev Tells whether the dynamic call targets are allowed + */ + function _areDynamicCallTargetsValid(bytes[] memory calls, bytes memory config) private pure returns (bool) { + for (uint256 i = 0; i < calls.length; i++) { + DynamicCall memory call = abi.decode(calls[i], (DynamicCall)); + if (!_isAccountAllowed(call.target, config)) return false; + } + return true; + } + + /** + * @dev Tells whether the dynamic call selectors are allowed + */ + function _areDynamicCallSelectorsValid(bytes[] memory calls, bytes memory config) private pure returns (bool) { + for (uint256 i = 0; i < calls.length; i++) { + DynamicCall memory call = abi.decode(calls[i], (DynamicCall)); + if (!_isSelectorAllowed(call.selector, config)) return false; + } + return true; + } +} diff --git a/packages/evm/contracts/safeguards/OperationsValidator.sol b/packages/evm/contracts/safeguards/OperationsValidator.sol index fb3ca10..6b379ac 100644 --- a/packages/evm/contracts/safeguards/OperationsValidator.sol +++ b/packages/evm/contracts/safeguards/OperationsValidator.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import './CallOperationsValidator.sol'; +import './DynamicCallOperationsValidator.sol'; import './TransferOperationsValidator.sol'; import './Safeguards.sol'; import './SwapOperationsValidator.sol'; @@ -17,7 +18,8 @@ contract OperationsValidator is IOperationsValidator, SwapOperationsValidator, TransferOperationsValidator, - CallOperationsValidator + CallOperationsValidator, + DynamicCallOperationsValidator { /** * @dev Safeguard validation failed @@ -139,7 +141,7 @@ contract OperationsValidator is } if (operation.opType == uint8(OpType.Transfer)) return _isTransferOperationValid(operation, safeguard); if (operation.opType == uint8(OpType.Call)) return _isCallOperationValid(operation, safeguard); - if (operation.opType == uint8(OpType.DynamicCall)) return true; // TODO: implement + if (operation.opType == uint8(OpType.DynamicCall)) return _isDynamicCallOperationValid(operation, safeguard); revert OperationsValidatorUnknownOperationType(uint8(operation.opType)); } diff --git a/packages/evm/test/safeguards/OperationsValidator.test.ts b/packages/evm/test/safeguards/OperationsValidator.test.ts index 784e6b6..d90cbbc 100644 --- a/packages/evm/test/safeguards/OperationsValidator.test.ts +++ b/packages/evm/test/safeguards/OperationsValidator.test.ts @@ -10,6 +10,7 @@ import { createDeniedAccountSafeguard, createDeniedChainSafeguard, createDeniedSelectorSafeguard, + createDynamicCallOperation, createListSafeguard, createOnlyAccountSafeguard, createOnlyChainSafeguard, @@ -460,6 +461,130 @@ describe('OperationsValidator', () => { }) }) }) + + describe('Dynamic call modes', () => { + const target1 = randomEvmAddress() + const target2 = randomEvmAddress() + const selector = '0xa9059cbb' + + context('None', () => { + const operation = createDynamicCallOperation() + const safeguard = createSafeguardNone() + + it('always reverts with OperationsValidatorNoneAllowed', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorNoneAllowed' + ) + }) + }) + + context('Chain', () => { + const operation = createDynamicCallOperation({ chainId: CHAIN_LOCAL, calls: [] }) + + context('when the chain is not denied', () => { + const safeguard = createOnlyChainSafeguard(CallSafeguardMode.Chain, CHAIN_LOCAL) + + it('passes', async () => { + expect(await validator.validate(operation, createListSafeguard(safeguard))).to.not.be.reverted + }) + }) + + context('when the chain is denied', () => { + const safeguard = createDeniedChainSafeguard(CallSafeguardMode.Chain, CHAIN_LOCAL) + + it('reverts', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorSafeguardFailed' + ) + }) + }) + + context('when the chain is not allowed', () => { + const safeguard = createOnlyChainSafeguard(CallSafeguardMode.Chain, CHAIN_OTHER) + + it('reverts', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorSafeguardFailed' + ) + }) + }) + }) + + context('Target', () => { + const operation = createDynamicCallOperation({ + calls: [{ target: target1, selector, arguments: [], value: 0 }], + }) + + context('when all targets are not denied', () => { + const safeguard = createOnlyAccountSafeguard(CallSafeguardMode.Target, target1) + + it('passes', async () => { + expect(await validator.validate(operation, createListSafeguard(safeguard))).to.not.be.reverted + }) + }) + + context('when the target is denied', () => { + const safeguard = createDeniedAccountSafeguard(CallSafeguardMode.Target, target1) + + it('reverts', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorSafeguardFailed' + ) + }) + }) + + context('when the target is not allowed', () => { + const safeguard = createOnlyAccountSafeguard(CallSafeguardMode.Target, target2) + + it('reverts', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorSafeguardFailed' + ) + }) + }) + }) + + context('Selector', () => { + const operation = createDynamicCallOperation({ + calls: [{ target: target1, selector, arguments: [], value: 0 }], + }) + + context('when the selector is allowed', () => { + const safeguard = createOnlySelectorSafeguard(selector) + + it('passes', async () => { + expect(await validator.validate(operation, createListSafeguard(safeguard))).to.not.be.reverted + }) + }) + + context('when the selector is denied', () => { + const safeguard = createDeniedSelectorSafeguard(selector) + + it('reverts', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorSafeguardFailed' + ) + }) + }) + + context('when the selector is not allowed', () => { + const safeguard = createOnlySelectorSafeguard(randomHex(4)) + + it('reverts', async () => { + await expect(validator.validate(operation, createListSafeguard(safeguard))).to.be.revertedWithCustomError( + validator, + 'OperationsValidatorSafeguardFailed' + ) + }) + }) + }) + }) }) describe('Tree', () => { From d09a02cecb1df061fe9ab821608c68e502c31c48 Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 8 Apr 2026 19:38:05 -0300 Subject: [PATCH 10/21] evm: allow dynamic calls to reference any operation output --- packages/evm/contracts/Settler.sol | 44 ++++++------- .../dynamic-calls/DynamicCallEncoder.sol | 24 ++++--- .../interfaces/IDynamicCallEncoder.sol | 2 +- .../test/dynamic-calls/StaticCallMock.sol | 2 +- .../contracts/test/utils/BytesHelpersMock.sol | 9 +++ packages/evm/contracts/utils/BytesHelpers.sol | 29 +++++++++ packages/evm/test/Settler.test.ts | 58 ++++++++++++++--- .../dynamic-calls/DynamicCallEncoder.test.ts | 43 ++++++++----- packages/evm/test/helpers/dynamic-calls.ts | 4 +- .../evm/test/helpers/intents/dynamic-call.ts | 2 +- packages/evm/test/utils/BytesHelpers.test.ts | 63 +++++++++++++++++++ 11 files changed, 221 insertions(+), 59 deletions(-) diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index a400802..705bacc 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -41,7 +41,7 @@ import './smart-accounts/SmartAccountsHandlerHelpers.sol'; */ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { using SafeERC20 for IERC20; - using BytesHelpers for bytes[]; + using BytesHelpers for bytes[][]; using IntentsHelpers for Intent; using IntentsHelpers for Proposal; using IntentsHelpers for Validation; @@ -207,9 +207,8 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _validateIntent(intent, proposal, signature, simulated); getIntentBlock[intent.hash()] = block.number; - bytes[] memory outputs = new bytes[](intent.operations.length); + bytes[][] memory outputs = new bytes[][](intent.operations.length); for (uint256 i = 0; i < intent.operations.length; i++) { - // TODO: support multiple outputs outputs[i] = _executeOperation(intent, proposal, i, outputs); } @@ -224,9 +223,9 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param index Position where the operation and its corresponding proposal data are located * @param outputs List of operations outputs */ - function _executeOperation(Intent memory intent, Proposal memory proposal, uint256 index, bytes[] memory outputs) + function _executeOperation(Intent memory intent, Proposal memory proposal, uint256 index, bytes[][] memory outputs) internal - returns (bytes memory) + returns (bytes[] memory) { uint8 opType = intent.operations[index].opType; if (opType == uint8(OpType.Swap) || opType == uint8(OpType.CrossChainSwap)) { @@ -248,7 +247,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { */ function _executeSwap(Intent memory intent, Proposal memory proposal, uint256 index) internal - returns (bytes memory) + returns (bytes[] memory outputs) { Operation memory operation = intent.operations[index]; SwapOperation memory swapOperation = abi.decode(operation.data, (SwapOperation)); @@ -269,25 +268,25 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { uint256[] memory preBalancesOut = _getTokensOutBalance(swapOperation); IExecutor(swapProposal.executor).execute(intent, proposal, index); - uint256[] memory outputs = new uint256[](swapOperation.tokensOut.length); + outputs = new bytes[](swapOperation.tokensOut.length); if (swapOperation.destinationChain == block.chainid) { + uint256[] memory amounts = new uint256[](swapOperation.tokensOut.length); for (uint256 i = 0; i < swapOperation.tokensOut.length; i++) { TokenOut memory tokenOut = swapOperation.tokensOut[i]; uint256 postBalanceOut = ERC20Helpers.balanceOf(tokenOut.token, address(this)); uint256 preBalanceOut = preBalancesOut[i]; if (postBalanceOut < preBalanceOut) revert SettlerPostBalanceOutLtPre(i, postBalanceOut, preBalanceOut); - outputs[i] = postBalanceOut - preBalanceOut; + amounts[i] = postBalanceOut - preBalanceOut; uint256 proposedAmount = swapProposal.amountsOut[i]; - if (outputs[i] < proposedAmount) revert SettlerAmountOutLtProposed(i, outputs[i], proposedAmount); + if (amounts[i] < proposedAmount) revert SettlerAmountOutLtProposed(i, amounts[i], proposedAmount); - ERC20Helpers.transfer(tokenOut.token, tokenOut.recipient, outputs[i]); + ERC20Helpers.transfer(tokenOut.token, tokenOut.recipient, amounts[i]); + outputs[i] = abi.encode(amounts[i]); } - _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); + _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(amounts)); } - - return outputs.length > 0 ? abi.encode(outputs[0]) : new bytes(0); } /** @@ -298,7 +297,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { */ function _executeTransfer(Intent memory intent, Proposal memory proposal, uint256 index) internal - returns (bytes memory) + returns (bytes[] memory outputs) { Operation memory operation = intent.operations[index]; TransferOperation memory transferOperation = abi.decode(operation.data, (TransferOperation)); @@ -310,9 +309,8 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _transferFrom(transfer.token, operation.user, transfer.recipient, transfer.amount, isSmartAccount); } - bytes memory output = new bytes(0); - _emitOperationEvents(operation, proposal, intent.hash(), index, output); - return output; + outputs = new bytes[](0); + _emitOperationEvents(operation, proposal, intent.hash(), index, new bytes(0)); } /** @@ -323,13 +321,13 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { */ function _executeCall(Intent memory intent, Proposal memory proposal, uint256 index) internal - returns (bytes memory) + returns (bytes[] memory outputs) { Operation memory operation = intent.operations[index]; CallOperation memory callOperation = abi.decode(operation.data, (CallOperation)); _validateCallOperation(callOperation, proposal.datas[index], operation.user); - bytes[] memory outputs = new bytes[](callOperation.calls.length); + outputs = new bytes[](callOperation.calls.length); for (uint256 i = 0; i < callOperation.calls.length; i++) { CallData memory call = callOperation.calls[i]; // solhint-disable-next-line avoid-low-level-calls @@ -337,7 +335,6 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { } _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); - return outputs.length > 0 ? outputs[0] : new bytes(0); } /** @@ -351,13 +348,13 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { Intent memory intent, Proposal memory proposal, uint256 index, - bytes[] memory variables - ) internal returns (bytes memory) { + bytes[][] memory variables + ) internal returns (bytes[] memory outputs) { Operation memory operation = intent.operations[index]; DynamicCallOperation memory dynamicCallOperation = abi.decode(operation.data, (DynamicCallOperation)); _validateDynamicCallOperation(dynamicCallOperation, proposal.datas[index], operation.user); - bytes[] memory outputs = new bytes[](dynamicCallOperation.calls.length); + outputs = new bytes[](dynamicCallOperation.calls.length); for (uint256 i = 0; i < dynamicCallOperation.calls.length; i++) { DynamicCall memory dynamicCall = abi.decode(dynamicCallOperation.calls[i], (DynamicCall)); bytes memory data = IDynamicCallEncoder(dynamicCallEncoder).encode(dynamicCall, variables); @@ -366,7 +363,6 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { } _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); - return outputs.length > 0 ? outputs[0] : new bytes(0); } /** diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol index 7628285..0195e67 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -53,7 +53,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { * @param variables List of resolved variable values * @return data Fully ABI-encoded calldata */ - function encode(DynamicCall memory dynamicCall, bytes[] memory variables) + function encode(DynamicCall memory dynamicCall, bytes[][] memory variables) external view override @@ -68,7 +68,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { * - static arguments are inlined in the head * - dynamic arguments place offsets in the head and append data to the tail */ - function _buildCalldata(bytes4 selector, DynamicArg[] memory args, bytes[] memory variables) + function _buildCalldata(bytes4 selector, DynamicArg[] memory args, bytes[][] memory variables) internal view returns (bytes memory data) @@ -105,7 +105,11 @@ contract DynamicCallEncoder is IDynamicCallEncoder { /** * @dev Encodes a single dynamic argument based on its kind */ - function _encodeArg(DynamicArg memory arg, bytes[] memory variables) internal view returns (EncodedArg memory out) { + function _encodeArg(DynamicArg memory arg, bytes[][] memory variables) + internal + view + returns (EncodedArg memory out) + { if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg.data); if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables); if (arg.kind == DynamicArgKind.StaticCall) return _encodeStaticCall(arg.data, variables); @@ -146,22 +150,24 @@ contract DynamicCallEncoder is IDynamicCallEncoder { /** * @dev Encodes a variable argument by resolving it from the variables list */ - function _encodeVariable(bytes memory data, bytes[] memory variables) + function _encodeVariable(bytes memory data, bytes[][] memory variables) internal pure returns (EncodedArg memory out) { - if (data.length != 32) revert DynamicCallEncoderVariableRefBadLength(); - uint256 index = data.readWord0(); - if (index >= variables.length) revert DynamicCallEncoderVariableOutOfBounds(); - out = _encodeFromAbiLikeBytes(variables[index]); + if (data.length != 64) revert DynamicCallEncoderVariableRefBadLength(); + uint256 opIndex = data.readWord0(); + uint256 subIndex = data.readWord1(); + if (opIndex >= variables.length) revert DynamicCallEncoderVariableOutOfBounds(); + if (subIndex >= variables[opIndex].length) revert DynamicCallEncoderVariableOutOfBounds(); + out = _encodeFromAbiLikeBytes(variables[opIndex][subIndex]); } /** * @dev Encodes a staticcall argument * Executes a staticcall and interprets the return data as an ABI value */ - function _encodeStaticCall(bytes memory data, bytes[] memory variables) + function _encodeStaticCall(bytes memory data, bytes[][] memory variables) internal view returns (EncodedArg memory out) diff --git a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol index 3248856..7fff586 100644 --- a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -72,5 +72,5 @@ interface IDynamicCallEncoder { * @param dynamicCall Dynamic call specification. * @param variables Resolved outputs from previous operations. */ - function encode(DynamicCall memory dynamicCall, bytes[] memory variables) external view returns (bytes memory); + function encode(DynamicCall memory dynamicCall, bytes[][] memory variables) external view returns (bytes memory); } diff --git a/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol index 55f7d7d..a6ae3e6 100644 --- a/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol +++ b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol @@ -7,7 +7,7 @@ contract StaticCallMock { return value; } - function returnAddress(address value) external pure returns (address) { + function returnAddress(address value) external payable returns (address) { return value; } diff --git a/packages/evm/contracts/test/utils/BytesHelpersMock.sol b/packages/evm/contracts/test/utils/BytesHelpersMock.sol index c54ce85..e324708 100644 --- a/packages/evm/contracts/test/utils/BytesHelpersMock.sol +++ b/packages/evm/contracts/test/utils/BytesHelpersMock.sol @@ -19,11 +19,16 @@ import '../../utils/BytesHelpers.sol'; contract BytesHelpersMock { using BytesHelpers for bytes; using BytesHelpers for bytes[]; + using BytesHelpers for bytes[][]; function readWord0(bytes memory data) external pure returns (uint256) { return data.readWord0(); } + function readWord1(bytes memory data) external pure returns (uint256) { + return data.readWord1(); + } + function lastWordIsZero(bytes memory data) external pure returns (bool) { return data.lastWordIsZero(); } @@ -39,4 +44,8 @@ contract BytesHelpersMock { function sliceArray(bytes[] memory data, uint256 start, uint256 end) external pure returns (bytes[] memory) { return data.slice(start, end); } + + function sliceMatrix(bytes[][] memory data, uint256 start, uint256 end) external pure returns (bytes[][] memory) { + return data.slice(start, end); + } } diff --git a/packages/evm/contracts/utils/BytesHelpers.sol b/packages/evm/contracts/utils/BytesHelpers.sol index 70fdd26..bc5b5cb 100644 --- a/packages/evm/contracts/utils/BytesHelpers.sol +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -33,6 +33,17 @@ library BytesHelpers { } } + /** + * @dev Reads the second 32-byte word of a bytes array + * @param data Bytes array to read from + * @return result Second ABI word of `data` + */ + function readWord1(bytes memory data) internal pure returns (uint256 result) { + assembly { + result := mload(add(data, 64)) + } + } + /** * @dev Checks whether the last 32-byte word of a bytes array is zero * @@ -99,4 +110,22 @@ library BytesHelpers { out[i] = slice(item, 0, item.length); } } + + /** + * @dev Returns a slice of a nested bytes array from `start` (inclusive) to `end` (exclusive) + * @param data Nested bytes array to slice + * @param start Starting item index (inclusive) + * @param end Ending item index (exclusive) + */ + function slice(bytes[][] memory data, uint256 start, uint256 end) internal pure returns (bytes[][] memory out) { + if (end < start) revert BytesLibSliceOutOfBounds(); + if (end > data.length) revert BytesLibSliceOutOfBounds(); + + uint256 len = end - start; + out = new bytes[][](len); + for (uint256 i = 0; i < len; i++) { + bytes[] memory item = data[start + i]; + out[i] = slice(item, 0, item.length); + } + } } diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 9dd73d9..a6d14c5 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -20,11 +20,12 @@ import { network } from 'hardhat' import { Controller, + DynamicCallEncoder, EmptyExecutorMock, MintExecutorMock, ReentrantExecutorMock, Settler, - SmartAccount, + SmartAccountBase as SmartAccount, TokenMock, TransferExecutorMock, } from '../types/ethers-contracts/index.js' @@ -50,6 +51,7 @@ import { createTransferOperation, createTransferProposal, currentTimestamp, + DYNAMIC_CALL_OP_TYPE, DynamicCallOperation, hashIntent, hashProposal, @@ -104,7 +106,7 @@ describe('Settler', () => { expect(await settler.smartAccountsHandler()).to.not.be.equal(ZERO_ADDRESS) }) - it.skip('has a dynamic call decoder', async () => { + it('has a dynamic call decoder', async () => { expect(await settler.dynamicCallEncoder()).to.not.be.equal(ZERO_ADDRESS) }) }) @@ -3031,8 +3033,10 @@ describe('Settler', () => { let target: Account let feeToken: TokenMock let proposal: Proposal + let dynamicCallEncoder: DynamicCallEncoder const argument = randomEvmAddress() + const value = fp(0.00001) const feeAmount = fp(0.01) const eventTopic = randomHex(32) const eventData = randomHex(120) @@ -3047,6 +3051,10 @@ describe('Settler', () => { await feeToken.mint(user, feeAmount) }) + beforeEach('fund smart account', async () => { + await owner.sendTransaction({ to: user, value }) + }) + beforeEach('create intent', async () => { intent = createDynamicCallIntent( { @@ -3061,6 +3069,7 @@ describe('Settler', () => { target, selector: target.interface.getFunction('returnAddress')!.selector, arguments: [literal(['address'], [argument])], + value, }, { target, @@ -3077,9 +3086,14 @@ describe('Settler', () => { proposal = createDynamicCallProposal({ fees: [feeAmount] }) }) + beforeEach('set dynamic call encoder', async () => { + dynamicCallEncoder = await ethers.deployContract('DynamicCallEncoder', []) + }) + it('executes the intent', async () => { const preUserBalance = await balanceOf(feeToken, user) const preSolverBalance = await balanceOf(feeToken, solver) + const preTargetBalance = await balanceOf(NATIVE_TOKEN_ADDRESS, target) const signature = await signProposal(settler, intent, solver, proposal, admin) await settler.execute(intent, proposal, signature) @@ -3089,6 +3103,9 @@ describe('Settler', () => { const postSolverBalance = await balanceOf(feeToken, solver) expect(postSolverBalance - preSolverBalance).to.be.eq(feeAmount) + + const postTargetBalance = await balanceOf(NATIVE_TOKEN_ADDRESS, target) + expect(postTargetBalance - preTargetBalance).to.be.eq(value) }) it('logs the intent events correctly', async () => { @@ -3100,7 +3117,7 @@ describe('Settler', () => { expect(events[0].args.user).to.be.equal(intent.operations[0].user) expect(events[0].args.topic).to.be.equal(eventTopic) - expect(events[0].args.opType).to.be.equal(4) + expect(events[0].args.opType).to.be.equal(DYNAMIC_CALL_OP_TYPE) expect(events[0].args.intentHash).to.be.equal(hashIntent(intent)) expect(events[0].args.data).to.be.equal(eventData) @@ -3113,6 +3130,33 @@ describe('Settler', () => { const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[1]) expect(decodedB).to.be.equal(18) }) + + it('reverts if the dynamic call references a later operation output', async () => { + intent.operations = [ + createDynamicCallOperation({ + user, + calls: [ + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [variable(1, 0)], + }, + ], + events: [{ topic: eventTopic, data: eventData }], + }), + createCallOperation({ + user, + calls: [{ target, data: target.interface.encodeFunctionData('returnUint', [1n]) }], + }), + ] + proposal.datas = ['0x', '0x'] + + const signature = await signProposal(settler, intent, solver, proposal, admin) + await expect(settler.execute(intent, proposal, signature)).to.be.revertedWithCustomError( + dynamicCallEncoder, + 'DynamicCallEncoderVariableOutOfBounds' + ) + }) }) context('swap + dynamic call', () => { @@ -3165,7 +3209,7 @@ describe('Settler', () => { { target, selector: target.interface.getFunction('returnUint')!.selector, - arguments: [variable(0)], + arguments: [variable(0, 1)], }, ], events: [{ topic: eventTopic, data: eventData }], @@ -3203,7 +3247,7 @@ describe('Settler', () => { const events = await settler.queryFilter(settler.filters.OperationExecuted(), tx.blockNumber) expect(events).to.have.lengthOf(1) - expect(events[0].args.opType).to.be.equal(4) + expect(events[0].args.opType).to.be.equal(DYNAMIC_CALL_OP_TYPE) expect(events[0].args.topic).to.be.equal(eventTopic) expect(events[0].args.data).to.be.equal(eventData) @@ -3211,12 +3255,12 @@ describe('Settler', () => { expect(outputs).to.have.lengthOf(1) const [decoded] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[0]) - expect(decoded).to.be.equal(swapAmountOutA) + expect(decoded).to.be.equal(swapAmountOutB) const callEvents = await smartAccount.queryFilter(smartAccount.filters.Called(), tx.blockNumber) expect(callEvents).to.have.lengthOf(1) expect(callEvents[0].args.data).to.be.equal( - target.interface.encodeFunctionData('returnUint', [swapAmountOutA]) + target.interface.encodeFunctionData('returnUint', [swapAmountOutB]) ) }) }) diff --git a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts index 1c65928..3cac75e 100644 --- a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -33,7 +33,7 @@ describe('DynamicCallEncoder', () => { describe('encode', () => { context('with literal arguments', () => { - const variables: string[] = [] + const variables: string[][] = [] context('with a single argument', () => { const owner = randomEvmAddress() @@ -73,14 +73,17 @@ describe('DynamicCallEncoder', () => { const var1 = randomEvmAddress() const var2 = [1, 2, 3, 4, 5, 6, 7] + // variables[opIndex][subIndex] const variables = [ - ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [var0]), - ethers.AbiCoder.defaultAbiCoder().encode(['address'], [var1]), - ethers.AbiCoder.defaultAbiCoder().encode(['uint256[]'], [var2]), + [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [var0]), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [var1]), + ], + [ethers.AbiCoder.defaultAbiCoder().encode(['uint256[]'], [var2])], ] context('with a single argument', () => { - const call = dynamicCall('balanceOf', [variable(1)]) + const call = dynamicCall('balanceOf', [variable(0, 1)]) it('encodes arguments properly', async () => { const encoded = await encoder.encode(call, variables) @@ -90,7 +93,7 @@ describe('DynamicCallEncoder', () => { context('with multiple arguments', () => { const to = randomEvmAddress() - const call = dynamicCall('transfer', [literal(['address'], [to]), variable(0)]) + const call = dynamicCall('transfer', [literal(['address'], [to]), variable(0, 0)]) it('encodes arguments properly', async () => { const encoded = await encoder.encode(call, variables) @@ -99,7 +102,7 @@ describe('DynamicCallEncoder', () => { }) context('with arbitrary-length arguments', () => { - const call = dynamicCall('foo', [variable(2)]) + const call = dynamicCall('foo', [variable(1, 0)]) it('encodes arguments properly', async () => { const encoded = await encoder.encode(call, variables) @@ -109,7 +112,7 @@ describe('DynamicCallEncoder', () => { }) context('when the variable spec is invalid', () => { - context('when variable ref is not 32 bytes', () => { + context('when variable ref is not 64 bytes', () => { const call = dynamicCall('foo', [{ kind: 1, data: '0x11' }]) it('reverts with DynamicCallEncoderVariableRefBadLength', async () => { @@ -120,8 +123,8 @@ describe('DynamicCallEncoder', () => { }) }) - context('when variable index is out of bounds', () => { - const call = dynamicCall('foo', [variable(0)]) + context('when operation index is out of bounds', () => { + const call = dynamicCall('foo', [variable(0, 0)]) it('reverts with DynamicCallEncoderVariableOutOfBounds', async () => { await expect(encoder.encode(call, [])).to.be.revertedWithCustomError( @@ -131,9 +134,21 @@ describe('DynamicCallEncoder', () => { }) }) + context('when sub-index is out of bounds', () => { + const variables = [[ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [1n])]] + const call = dynamicCall('foo', [variable(0, 1)]) + + it('reverts with DynamicCallEncoderVariableOutOfBounds', async () => { + await expect(encoder.encode(call, variables)).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariableOutOfBounds' + ) + }) + }) + context('when variable bytes are too short to be static', () => { - const variables = ['0x1234'] - const call = dynamicCall('transfer', [literal(['address'], [randomEvmAddress()]), variable(0)]) + const variables = [['0x1234']] + const call = dynamicCall('transfer', [literal(['address'], [randomEvmAddress()]), variable(0, 0)]) it('reverts with DynamicCallEncoderVariableTooShort', async () => { await expect(encoder.encode(call, variables)).to.be.revertedWithCustomError( @@ -188,10 +203,10 @@ describe('DynamicCallEncoder', () => { context('when the staticcall receives a variable', () => { it('encodes arguments properly', async () => { const owner = randomEvmAddress() - const variables = [ethers.AbiCoder.defaultAbiCoder().encode(['address'], [owner])] + const variables = [[ethers.AbiCoder.defaultAbiCoder().encode(['address'], [owner])]] const call = dynamicCall('balanceOf', [ - staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [variable(0)]), + staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [variable(0, 0)]), ]) const encoded = await encoder.encode(call, variables) diff --git a/packages/evm/test/helpers/dynamic-calls.ts b/packages/evm/test/helpers/dynamic-calls.ts index 8580af5..94e0b8e 100644 --- a/packages/evm/test/helpers/dynamic-calls.ts +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -7,8 +7,8 @@ export function literal(types: string[], values: any[]): DynamicArg { return { kind: 0, data } } -export function variable(index: number): DynamicArg { - const data = AbiCoder.defaultAbiCoder().encode(['uint256'], [index]) +export function variable(opIndex: number, subIndex: number): DynamicArg { + const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [opIndex, subIndex]) return { kind: 1, data } } diff --git a/packages/evm/test/helpers/intents/dynamic-call.ts b/packages/evm/test/helpers/intents/dynamic-call.ts index ac5e039..c086046 100644 --- a/packages/evm/test/helpers/intents/dynamic-call.ts +++ b/packages/evm/test/helpers/intents/dynamic-call.ts @@ -4,7 +4,7 @@ import { Account, toAddress } from '../addresses.js' import { DynamicArg } from '../dynamic-calls.js' import { createIntent, createOperation, Intent, Operation } from './base.js' -const DYNAMIC_CALL_OP_TYPE = 4 +export const DYNAMIC_CALL_OP_TYPE = 4 export type DynamicCallOperation = Operation & { chainId: BigNumberish diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts index 5959a4d..69ce711 100644 --- a/packages/evm/test/utils/BytesHelpers.test.ts +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -36,6 +36,29 @@ describe('BytesHelpers', () => { }) }) + describe('readWord1', () => { + context('when data is 64 bytes', () => { + const a = 999n + const b = 555n + const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [a, b]) + + it('returns the second word', async () => { + expect(await library.readWord1(data)).to.equal(b) + }) + }) + + context('when data is longer than 64 bytes', () => { + const a = 999n + const b = 555n + const c = 111n + const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256', 'uint256'], [a, b, c]) + + it('returns the second word', async () => { + expect(await library.readWord1(data)).to.equal(b) + }) + }) + }) + describe('lastWordIsZero', () => { context('when the last word is zero', () => { const data = ethers.concat([AbiCoder.defaultAbiCoder().encode(['uint256'], [1n]), ethers.ZeroHash]) @@ -185,4 +208,44 @@ describe('BytesHelpers', () => { }) }) }) + + describe('slice(bytes[][])', () => { + const data = [['0x11'], ['0x2233', '0x445566'], ['0x778899aa']] + + context('when slicing a prefix', () => { + it('returns the expected rows', async () => { + const out = await library.sliceMatrix(data, 0, 2) + expect(out).to.deep.equal([['0x11'], ['0x2233', '0x445566']]) + }) + }) + + context('when slicing a middle range', () => { + it('returns the expected rows', async () => { + const out = await library.sliceMatrix(data, 1, 3) + expect(out).to.deep.equal([['0x2233', '0x445566'], ['0x778899aa']]) + }) + }) + + context('when slicing an empty range', () => { + it('returns an empty array', async () => { + const out = await library.sliceMatrix(data, 1, 1) + expect(out).to.deep.equal([]) + }) + }) + + context('when end is smaller than start', () => { + it('reverts', async () => { + await expect(library.sliceMatrix(data, 2, 1)).to.be.revertedWithCustomError(library, 'BytesLibSliceOutOfBounds') + }) + }) + + context('when end is out of bounds', () => { + it('reverts', async () => { + await expect(library.sliceMatrix(data, 0, data.length + 1)).to.be.revertedWithCustomError( + library, + 'BytesLibSliceOutOfBounds' + ) + }) + }) + }) }) From 8abb986682cdba58234bc609e55ae773402ef215 Mon Sep 17 00:00:00 2001 From: lgalende Date: Thu, 9 Apr 2026 12:31:21 -0300 Subject: [PATCH 11/21] evm: avoid slicing outputs in dynamic call execution --- packages/evm/contracts/Settler.sol | 10 +-- .../dynamic-calls/DynamicCallEncoder.sol | 33 +++---- .../interfaces/IDynamicCallEncoder.sol | 13 ++- .../contracts/test/utils/BytesHelpersMock.sol | 10 --- packages/evm/contracts/utils/BytesHelpers.sol | 36 -------- .../dynamic-calls/DynamicCallEncoder.test.ts | 45 ++++++---- packages/evm/test/utils/BytesHelpers.test.ts | 87 ------------------- 7 files changed, 62 insertions(+), 172 deletions(-) diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index 705bacc..317f360 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -29,7 +29,6 @@ import './interfaces/IDynamicCallEncoder.sol'; import './interfaces/IOperationsValidator.sol'; import './interfaces/IExecutor.sol'; import './interfaces/ISettler.sol'; -import './utils/BytesHelpers.sol'; import './utils/Denominations.sol'; import './utils/ERC20Helpers.sol'; import './smart-accounts/SmartAccountsHandler.sol'; @@ -41,7 +40,6 @@ import './smart-accounts/SmartAccountsHandlerHelpers.sol'; */ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { using SafeERC20 for IERC20; - using BytesHelpers for bytes[][]; using IntentsHelpers for Intent; using IntentsHelpers for Proposal; using IntentsHelpers for Validation; @@ -233,9 +231,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { } if (opType == uint8(OpType.Transfer)) return _executeTransfer(intent, proposal, index); if (opType == uint8(OpType.Call)) return _executeCall(intent, proposal, index); - if (opType == uint8(OpType.DynamicCall)) { - return _executeDynamicCall(intent, proposal, index, outputs.slice(0, index)); - } + if (opType == uint8(OpType.DynamicCall)) return _executeDynamicCall(intent, proposal, index, outputs); revert SettlerUnknownOperationType(opType); } @@ -342,7 +338,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param intent Intent that contains dynamic call operation to be fulfilled * @param proposal Dynamic call proposal to be executed * @param index Position where the dynamic call proposal data and operation are located - * @param variables List of previous operations outputs + * @param variables List of operations outputs */ function _executeDynamicCall( Intent memory intent, @@ -357,7 +353,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { outputs = new bytes[](dynamicCallOperation.calls.length); for (uint256 i = 0; i < dynamicCallOperation.calls.length; i++) { DynamicCall memory dynamicCall = abi.decode(dynamicCallOperation.calls[i], (DynamicCall)); - bytes memory data = IDynamicCallEncoder(dynamicCallEncoder).encode(dynamicCall, variables); + bytes memory data = IDynamicCallEncoder(dynamicCallEncoder).encode(dynamicCall, variables, index); // solhint-disable-next-line avoid-low-level-calls outputs[i] = smartAccountsHandler.call(operation.user, dynamicCall.target, data, dynamicCall.value); } diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol index 0195e67..58ef08a 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -51,15 +51,17 @@ contract DynamicCallEncoder is IDynamicCallEncoder { * @dev Encodes a dynamic call into calldata * @param dynamicCall Dynamic call specification * @param variables List of resolved variable values + * @param variablesLength Number of resolved variables * @return data Fully ABI-encoded calldata */ - function encode(DynamicCall memory dynamicCall, bytes[][] memory variables) + function encode(DynamicCall memory dynamicCall, bytes[][] memory variables, uint256 variablesLength) external view override returns (bytes memory data) { - data = _buildCalldata(dynamicCall.selector, dynamicCall.arguments, variables); + if (variablesLength > variables.length) revert DynamicCallEncoderVariablesLengthOutOfBounds(); + data = _buildCalldata(dynamicCall.selector, dynamicCall.arguments, variables, variablesLength); } /** @@ -68,18 +70,19 @@ contract DynamicCallEncoder is IDynamicCallEncoder { * - static arguments are inlined in the head * - dynamic arguments place offsets in the head and append data to the tail */ - function _buildCalldata(bytes4 selector, DynamicArg[] memory args, bytes[][] memory variables) - internal - view - returns (bytes memory data) - { + function _buildCalldata( + bytes4 selector, + DynamicArg[] memory args, + bytes[][] memory variables, + uint256 variablesLength + ) internal view returns (bytes memory data) { uint256 n = args.length; bytes[] memory encodedArgs = new bytes[](n); bool[] memory isDynamic = new bool[](n); uint256 headLength = 0; for (uint256 i = 0; i < n; i++) { - EncodedArg memory enc = _encodeArg(args[i], variables); + EncodedArg memory enc = _encodeArg(args[i], variables, variablesLength); encodedArgs[i] = enc.data; isDynamic[i] = enc.isDynamic; headLength += enc.headLength; @@ -105,14 +108,14 @@ contract DynamicCallEncoder is IDynamicCallEncoder { /** * @dev Encodes a single dynamic argument based on its kind */ - function _encodeArg(DynamicArg memory arg, bytes[][] memory variables) + function _encodeArg(DynamicArg memory arg, bytes[][] memory variables, uint256 variablesLength) internal view returns (EncodedArg memory out) { if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg.data); - if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables); - if (arg.kind == DynamicArgKind.StaticCall) return _encodeStaticCall(arg.data, variables); + if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables, variablesLength); + if (arg.kind == DynamicArgKind.StaticCall) return _encodeStaticCall(arg.data, variables, variablesLength); revert DynamicCallEncoderStaticCallBadSpec(); } @@ -150,7 +153,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { /** * @dev Encodes a variable argument by resolving it from the variables list */ - function _encodeVariable(bytes memory data, bytes[][] memory variables) + function _encodeVariable(bytes memory data, bytes[][] memory variables, uint256 variablesLength) internal pure returns (EncodedArg memory out) @@ -158,7 +161,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { if (data.length != 64) revert DynamicCallEncoderVariableRefBadLength(); uint256 opIndex = data.readWord0(); uint256 subIndex = data.readWord1(); - if (opIndex >= variables.length) revert DynamicCallEncoderVariableOutOfBounds(); + if (opIndex >= variablesLength) revert DynamicCallEncoderVariableOutOfBounds(); if (subIndex >= variables[opIndex].length) revert DynamicCallEncoderVariableOutOfBounds(); out = _encodeFromAbiLikeBytes(variables[opIndex][subIndex]); } @@ -167,14 +170,14 @@ contract DynamicCallEncoder is IDynamicCallEncoder { * @dev Encodes a staticcall argument * Executes a staticcall and interprets the return data as an ABI value */ - function _encodeStaticCall(bytes memory data, bytes[][] memory variables) + function _encodeStaticCall(bytes memory data, bytes[][] memory variables, uint256 variablesLength) internal view returns (EncodedArg memory out) { if (data.length < 64) revert DynamicCallEncoderStaticCallBadSpec(); DynamicStaticCallArg memory spec = abi.decode(data, (DynamicStaticCallArg)); - bytes memory callData = _buildCalldata(spec.selector, spec.arguments, variables); + bytes memory callData = _buildCalldata(spec.selector, spec.arguments, variables, variablesLength); (bool ok, bytes memory result) = spec.target.staticcall(callData); if (!ok) revert DynamicCallEncoderStaticCallFailed(spec.target); out = _encodeFromAbiLikeBytes(result); diff --git a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol index 7fff586..79e4a5c 100644 --- a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -52,6 +52,11 @@ interface IDynamicCallEncoder { */ error DynamicCallEncoderVariableOutOfBounds(); + /** + * @dev The declared variables length exceeds the variables array length + */ + error DynamicCallEncoderVariablesLengthOutOfBounds(); + /** * @dev The variable value is too short to be interpreted */ @@ -70,7 +75,11 @@ interface IDynamicCallEncoder { /** * @dev Encodes a dynamic call into calldata. * @param dynamicCall Dynamic call specification. - * @param variables Resolved outputs from previous operations. + * @param variables List of resolved variable values. + * @param variablesLength Number of resolved variables. */ - function encode(DynamicCall memory dynamicCall, bytes[][] memory variables) external view returns (bytes memory); + function encode(DynamicCall memory dynamicCall, bytes[][] memory variables, uint256 variablesLength) + external + view + returns (bytes memory); } diff --git a/packages/evm/contracts/test/utils/BytesHelpersMock.sol b/packages/evm/contracts/test/utils/BytesHelpersMock.sol index e324708..c956f06 100644 --- a/packages/evm/contracts/test/utils/BytesHelpersMock.sol +++ b/packages/evm/contracts/test/utils/BytesHelpersMock.sol @@ -18,8 +18,6 @@ import '../../utils/BytesHelpers.sol'; contract BytesHelpersMock { using BytesHelpers for bytes; - using BytesHelpers for bytes[]; - using BytesHelpers for bytes[][]; function readWord0(bytes memory data) external pure returns (uint256) { return data.readWord0(); @@ -40,12 +38,4 @@ contract BytesHelpersMock { function sliceFrom(bytes memory data, uint256 start) external pure returns (bytes memory) { return data.sliceFrom(start); } - - function sliceArray(bytes[] memory data, uint256 start, uint256 end) external pure returns (bytes[] memory) { - return data.slice(start, end); - } - - function sliceMatrix(bytes[][] memory data, uint256 start, uint256 end) external pure returns (bytes[][] memory) { - return data.slice(start, end); - } } diff --git a/packages/evm/contracts/utils/BytesHelpers.sol b/packages/evm/contracts/utils/BytesHelpers.sol index bc5b5cb..4616046 100644 --- a/packages/evm/contracts/utils/BytesHelpers.sol +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -92,40 +92,4 @@ library BytesHelpers { function sliceFrom(bytes memory data, uint256 start) internal pure returns (bytes memory out) { return slice(data, start, data.length); } - - /** - * @dev Returns a slice of a bytes array from `start` (inclusive) to `end` (exclusive) - * @param data Bytes array to slice - * @param start Starting item index (inclusive) - * @param end Ending item index (exclusive) - */ - function slice(bytes[] memory data, uint256 start, uint256 end) internal pure returns (bytes[] memory out) { - if (end < start) revert BytesLibSliceOutOfBounds(); - if (end > data.length) revert BytesLibSliceOutOfBounds(); - - uint256 len = end - start; - out = new bytes[](len); - for (uint256 i = 0; i < len; i++) { - bytes memory item = data[start + i]; - out[i] = slice(item, 0, item.length); - } - } - - /** - * @dev Returns a slice of a nested bytes array from `start` (inclusive) to `end` (exclusive) - * @param data Nested bytes array to slice - * @param start Starting item index (inclusive) - * @param end Ending item index (exclusive) - */ - function slice(bytes[][] memory data, uint256 start, uint256 end) internal pure returns (bytes[][] memory out) { - if (end < start) revert BytesLibSliceOutOfBounds(); - if (end > data.length) revert BytesLibSliceOutOfBounds(); - - uint256 len = end - start; - out = new bytes[][](len); - for (uint256 i = 0; i < len; i++) { - bytes[] memory item = data[start + i]; - out[i] = slice(item, 0, item.length); - } - } } diff --git a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts index 3cac75e..ab665cf 100644 --- a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -40,7 +40,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('balanceOf', [literal(['address'], [owner])]) it('encodes arguments properly', async () => { - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [owner])) }) }) @@ -51,7 +51,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('transfer', [literal(['address'], [to]), literal(['uint256'], [amount])]) it('encodes arguments properly', async () => { - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, amount])) }) }) @@ -61,7 +61,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('foo', [literal(['uint256[]'], [values])]) it('encodes arguments properly', async () => { - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) }) }) @@ -86,7 +86,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('balanceOf', [variable(0, 1)]) it('encodes arguments properly', async () => { - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [var1])) }) }) @@ -96,7 +96,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('transfer', [literal(['address'], [to]), variable(0, 0)]) it('encodes arguments properly', async () => { - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, var0])) }) }) @@ -105,7 +105,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('foo', [variable(1, 0)]) it('encodes arguments properly', async () => { - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('foo', [var2])) }) }) @@ -116,7 +116,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('foo', [{ kind: 1, data: '0x11' }]) it('reverts with DynamicCallEncoderVariableRefBadLength', async () => { - await expect(encoder.encode(call, [])).to.be.revertedWithCustomError( + await expect(encoder.encode(call, [], 0)).to.be.revertedWithCustomError( encoder, 'DynamicCallEncoderVariableRefBadLength' ) @@ -124,10 +124,13 @@ describe('DynamicCallEncoder', () => { }) context('when operation index is out of bounds', () => { - const call = dynamicCall('foo', [variable(0, 0)]) + const var0 = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [1n])] + const variables = [var0, var0] // variables.length = 2 + const variablesLength = 1 + const call = dynamicCall('foo', [variable(1, 0)]) it('reverts with DynamicCallEncoderVariableOutOfBounds', async () => { - await expect(encoder.encode(call, [])).to.be.revertedWithCustomError( + await expect(encoder.encode(call, variables, variablesLength)).to.be.revertedWithCustomError( encoder, 'DynamicCallEncoderVariableOutOfBounds' ) @@ -139,7 +142,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('foo', [variable(0, 1)]) it('reverts with DynamicCallEncoderVariableOutOfBounds', async () => { - await expect(encoder.encode(call, variables)).to.be.revertedWithCustomError( + await expect(encoder.encode(call, variables, variables.length)).to.be.revertedWithCustomError( encoder, 'DynamicCallEncoderVariableOutOfBounds' ) @@ -151,7 +154,7 @@ describe('DynamicCallEncoder', () => { const call = dynamicCall('transfer', [literal(['address'], [randomEvmAddress()]), variable(0, 0)]) it('reverts with DynamicCallEncoderVariableTooShort', async () => { - await expect(encoder.encode(call, variables)).to.be.revertedWithCustomError( + await expect(encoder.encode(call, variables, variables.length)).to.be.revertedWithCustomError( encoder, 'DynamicCallEncoderVariableTooShort' ) @@ -179,7 +182,7 @@ describe('DynamicCallEncoder', () => { ]), literal(['uint256'], [amount]), ]) - const encoded = await encoder.encode(call, []) + const encoded = await encoder.encode(call, [], 0) expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, amount])) }) }) @@ -194,7 +197,7 @@ describe('DynamicCallEncoder', () => { ]), ]) - const encoded = await encoder.encode(call, []) + const encoded = await encoder.encode(call, [], 0) expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) }) }) @@ -209,7 +212,7 @@ describe('DynamicCallEncoder', () => { staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [variable(0, 0)]), ]) - const encoded = await encoder.encode(call, variables) + const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [owner])) }) }) @@ -226,10 +229,22 @@ describe('DynamicCallEncoder', () => { ]), ]) - const encoded = await encoder.encode(call, []) + const encoded = await encoder.encode(call, [], 0) expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [to])) }) }) }) + + context('when variables length exceeds the variables array length', () => { + const variables = [[ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [1n])]] + const call = dynamicCall('foo', [variable(0, 0)]) + + it('reverts with DynamicCallEncoderVariablesLengthOutOfBounds', async () => { + await expect(encoder.encode(call, variables, variables.length + 1)).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariablesLengthOutOfBounds' + ) + }) + }) }) }) diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts index 69ce711..827064e 100644 --- a/packages/evm/test/utils/BytesHelpers.test.ts +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -161,91 +161,4 @@ describe('BytesHelpers', () => { }) }) }) - - describe('slice(bytes[])', () => { - const data = ['0x11', '0x2233', '0x445566'] - - context('when slicing a prefix', () => { - it('returns the expected items', async () => { - const out = await library.sliceArray(data, 0, 2) - expect(out).to.deep.equal(['0x11', '0x2233']) - }) - }) - - context('when slicing a middle range', () => { - it('returns the expected items', async () => { - const out = await library.sliceArray(data, 1, 3) - expect(out).to.deep.equal(['0x2233', '0x445566']) - }) - }) - - context('when slicing the full array', () => { - it('returns all items', async () => { - const out = await library.sliceArray(data, 0, data.length) - expect(out).to.deep.equal(data) - }) - }) - - context('when slicing an empty range', () => { - it('returns an empty array', async () => { - const out = await library.sliceArray(data, 1, 1) - expect(out).to.deep.equal([]) - }) - }) - - context('when end is smaller than start', () => { - it('reverts', async () => { - await expect(library.sliceArray(data, 2, 1)).to.be.revertedWithCustomError(library, 'BytesLibSliceOutOfBounds') - }) - }) - - context('when end is out of bounds', () => { - it('reverts', async () => { - await expect(library.sliceArray(data, 0, data.length + 1)).to.be.revertedWithCustomError( - library, - 'BytesLibSliceOutOfBounds' - ) - }) - }) - }) - - describe('slice(bytes[][])', () => { - const data = [['0x11'], ['0x2233', '0x445566'], ['0x778899aa']] - - context('when slicing a prefix', () => { - it('returns the expected rows', async () => { - const out = await library.sliceMatrix(data, 0, 2) - expect(out).to.deep.equal([['0x11'], ['0x2233', '0x445566']]) - }) - }) - - context('when slicing a middle range', () => { - it('returns the expected rows', async () => { - const out = await library.sliceMatrix(data, 1, 3) - expect(out).to.deep.equal([['0x2233', '0x445566'], ['0x778899aa']]) - }) - }) - - context('when slicing an empty range', () => { - it('returns an empty array', async () => { - const out = await library.sliceMatrix(data, 1, 1) - expect(out).to.deep.equal([]) - }) - }) - - context('when end is smaller than start', () => { - it('reverts', async () => { - await expect(library.sliceMatrix(data, 2, 1)).to.be.revertedWithCustomError(library, 'BytesLibSliceOutOfBounds') - }) - }) - - context('when end is out of bounds', () => { - it('reverts', async () => { - await expect(library.sliceMatrix(data, 0, data.length + 1)).to.be.revertedWithCustomError( - library, - 'BytesLibSliceOutOfBounds' - ) - }) - }) - }) }) From 826cdf7e24bc3ea85a214b81ad024995b4f2af11 Mon Sep 17 00:00:00 2001 From: lgalende Date: Thu, 9 Apr 2026 13:03:43 -0300 Subject: [PATCH 12/21] evm: add dynamic call encoder setter --- packages/evm/contracts/Settler.sol | 18 +++++++ .../evm/contracts/interfaces/ISettler.sol | 16 +++++++ packages/evm/test/Settler.test.ts | 48 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index 317f360..e2e5ad9 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -151,6 +151,14 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _setOperationsValidator(newOperationsValidator); } + /** + * @dev Sets a new dynamic call encoder address + * @param newDynamicCallEncoder New dynamic call encoder to be set + */ + function setDynamicCallEncoder(address newDynamicCallEncoder) external override onlyOwner { + _setDynamicCallEncoder(newDynamicCallEncoder); + } + /** * @dev Sets a safeguard for a user * @param safeguard Safeguard to be set @@ -636,6 +644,16 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { emit OperationsValidatorSet(newOperationsValidator); } + /** + * @dev Sets the dynamic call encoder + * @param newDynamicCallEncoder New dynamic call encoder to be set + */ + function _setDynamicCallEncoder(address newDynamicCallEncoder) internal { + if (newDynamicCallEncoder == address(0)) revert SettlerDynamicCallEncoderZero(); + dynamicCallEncoder = newDynamicCallEncoder; + emit DynamicCallEncoderSet(newDynamicCallEncoder); + } + /** * @dev Sets a safeguard for a user * @param user Address of the user to set the safeguard for diff --git a/packages/evm/contracts/interfaces/ISettler.sol b/packages/evm/contracts/interfaces/ISettler.sol index a876e92..80d1ff5 100644 --- a/packages/evm/contracts/interfaces/ISettler.sol +++ b/packages/evm/contracts/interfaces/ISettler.sol @@ -159,6 +159,11 @@ interface ISettler { */ error SmartAccountsHandlerZero(); + /** + * @dev The new dynamic call encoder is zero + */ + error SettlerDynamicCallEncoderZero(); + /** * @dev Custom events emitted for each operation */ @@ -194,6 +199,11 @@ interface ISettler { */ event OperationsValidatorSet(address indexed operationsValidator); + /** + * @dev Emitted every time the dynamic call encoder is set + */ + event DynamicCallEncoderSet(address indexed dynamicCallEncoder); + /** * @dev Emitted every time a safeguard is set */ @@ -268,6 +278,12 @@ interface ISettler { */ function setOperationsValidator(address newOperationsValidator) external; + /** + * @dev Sets a new dynamic call encoder address + * @param newDynamicCallEncoder New dynamic call encoder to be set + */ + function setDynamicCallEncoder(address newDynamicCallEncoder) external; + /** * @dev Sets a safeguard for a user * @param safeguard Safeguard to be set diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index a6d14c5..8a13575 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -355,6 +355,54 @@ describe('Settler', () => { }) }) + describe('setDynamicCallEncoder', () => { + context('when the sender is the owner', () => { + beforeEach('set sender', () => { + settler = settler.connect(owner) + }) + + context('when the dynamic call encoder is not zero', () => { + let newDynamicCallEncoder: DynamicCallEncoder + + beforeEach('deploy encoder', async () => { + newDynamicCallEncoder = await ethers.deployContract('DynamicCallEncoder', []) + }) + + it('sets the dynamic call encoder and emits an event', async () => { + const tx = await settler.setDynamicCallEncoder(newDynamicCallEncoder) + + expect(await settler.dynamicCallEncoder()).to.equal(newDynamicCallEncoder) + + const events = await settler.queryFilter(settler.filters.DynamicCallEncoderSet(), tx.blockNumber) + expect(events).to.have.lengthOf(1) + expect(events[0].args.dynamicCallEncoder).to.equal(newDynamicCallEncoder) + }) + }) + + context('when the dynamic call encoder is zero', () => { + it('reverts', async () => { + await expect(settler.setDynamicCallEncoder(ZERO_ADDRESS)).to.be.revertedWithCustomError( + settler, + 'SettlerDynamicCallEncoderZero' + ) + }) + }) + }) + + context('when the sender is not the owner', () => { + beforeEach('set sender', () => { + settler = settler.connect(user) + }) + + it('reverts', async () => { + await expect(settler.setDynamicCallEncoder(ZERO_ADDRESS)).to.be.revertedWithCustomError( + settler, + 'OwnableUnauthorizedAccount' + ) + }) + }) + }) + describe('setSafeguard', () => { const safeguard = randomHex(64) From 4d82582c7e4f703b55127653a195a6949bc5d276 Mon Sep 17 00:00:00 2001 From: lgalende Date: Fri, 10 Apr 2026 15:15:50 -0300 Subject: [PATCH 13/21] chore: remove lint warnings --- packages/evm/test/Settler.test.ts | 6 +++--- packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts | 1 + packages/evm/test/helpers/dynamic-calls.ts | 3 ++- packages/evm/test/helpers/intents/dynamic-call.ts | 5 ++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 8a13575..cb43ed1 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -51,7 +51,6 @@ import { createTransferOperation, createTransferProposal, currentTimestamp, - DYNAMIC_CALL_OP_TYPE, DynamicCallOperation, hashIntent, hashProposal, @@ -73,6 +72,7 @@ import { addValidations } from './helpers/validations' const { ethers } = await network.connect() /* eslint-disable no-secrets/no-secrets */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ describe('Settler', () => { let settler: Settler, controller: Controller @@ -3165,7 +3165,7 @@ describe('Settler', () => { expect(events[0].args.user).to.be.equal(intent.operations[0].user) expect(events[0].args.topic).to.be.equal(eventTopic) - expect(events[0].args.opType).to.be.equal(DYNAMIC_CALL_OP_TYPE) + expect(events[0].args.opType).to.be.equal(OpType.EvmDynamicCall) expect(events[0].args.intentHash).to.be.equal(hashIntent(intent)) expect(events[0].args.data).to.be.equal(eventData) @@ -3295,7 +3295,7 @@ describe('Settler', () => { const events = await settler.queryFilter(settler.filters.OperationExecuted(), tx.blockNumber) expect(events).to.have.lengthOf(1) - expect(events[0].args.opType).to.be.equal(DYNAMIC_CALL_OP_TYPE) + expect(events[0].args.opType).to.be.equal(OpType.EvmDynamicCall) expect(events[0].args.topic).to.be.equal(eventTopic) expect(events[0].args.data).to.be.equal(eventData) diff --git a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts index ab665cf..535412e 100644 --- a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -8,6 +8,7 @@ import { DynamicArg, literal, staticCall, variable } from '../helpers' const { ethers } = await network.connect() /* eslint-disable no-secrets/no-secrets */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ describe('DynamicCallEncoder', () => { let encoder: DynamicCallEncoder diff --git a/packages/evm/test/helpers/dynamic-calls.ts b/packages/evm/test/helpers/dynamic-calls.ts index 94e0b8e..1cd84de 100644 --- a/packages/evm/test/helpers/dynamic-calls.ts +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -2,6 +2,7 @@ import { AbiCoder } from 'ethers' export type DynamicArg = { kind: number; data: string } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function literal(types: string[], values: any[]): DynamicArg { const data = AbiCoder.defaultAbiCoder().encode(['string', ...types], ['', ...values]) return { kind: 0, data } @@ -12,7 +13,7 @@ export function variable(opIndex: number, subIndex: number): DynamicArg { return { kind: 1, data } } -export function staticCall(target: string, selector: string, args: DynamicArg[]) { +export function staticCall(target: string, selector: string, args: DynamicArg[]): DynamicArg { const data = AbiCoder.defaultAbiCoder().encode( ['tuple(address target, bytes4 selector, tuple(uint8 kind, bytes data)[] arguments)'], [{ target, selector, arguments: args }] diff --git a/packages/evm/test/helpers/intents/dynamic-call.ts b/packages/evm/test/helpers/intents/dynamic-call.ts index c086046..96a0cc1 100644 --- a/packages/evm/test/helpers/intents/dynamic-call.ts +++ b/packages/evm/test/helpers/intents/dynamic-call.ts @@ -1,11 +1,10 @@ +import { OpType } from '@mimicprotocol/sdk' import { AbiCoder, BigNumberish } from 'ethers' import { Account, toAddress } from '../addresses.js' import { DynamicArg } from '../dynamic-calls.js' import { createIntent, createOperation, Intent, Operation } from './base.js' -export const DYNAMIC_CALL_OP_TYPE = 4 - export type DynamicCallOperation = Operation & { chainId: BigNumberish calls: DynamicCallData[] @@ -29,7 +28,7 @@ export function createDynamicCallIntent( } export function createDynamicCallOperation(params?: Partial): Operation { - const operation = createOperation({ ...params, opType: DYNAMIC_CALL_OP_TYPE }) + const operation = createOperation({ ...params, opType: OpType.EvmDynamicCall }) const dynamicCallOperation = { ...getDefaults(), ...params, ...operation } as DynamicCallOperation operation.data = AbiCoder.defaultAbiCoder().encode( ['tuple(uint256 chainId, bytes[] calls)'], From 0a045c593d6c7c64621bf007273f487a1cd27372 Mon Sep 17 00:00:00 2001 From: lgalende Date: Fri, 17 Apr 2026 15:32:50 -0300 Subject: [PATCH 14/21] evm: remove staticcall dynamic arg kind --- packages/evm/contracts/Intents.sol | 2 - .../dynamic-calls/DynamicCallEncoder.sol | 27 +------ .../dynamic-calls/DynamicCallTypes.sol | 17 +---- .../interfaces/IDynamicCallEncoder.sol | 5 -- .../DynamicCallOperationsValidator.sol | 1 + packages/evm/test/Settler.test.ts | 51 +++++++++---- .../dynamic-calls/DynamicCallEncoder.test.ts | 76 +------------------ packages/evm/test/helpers/dynamic-calls.ts | 8 -- 8 files changed, 43 insertions(+), 144 deletions(-) diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index ac44c97..455e84d 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -2,8 +2,6 @@ pragma solidity ^0.8.20; -import './dynamic-calls/DynamicCallTypes.sol'; - /** * @dev Enum representing the operation type. * - Swap: Swap tokens in the same chain. diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol index 58ef08a..521c20c 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -25,7 +25,6 @@ import '../utils/BytesHelpers.sol'; * This encoder supports: * - Literal ABI-encoded arguments * - Variable references resolved from previous execution results - * - Nested static calls whose return values are used as arguments * * The encoder follows standard ABI encoding rules, reconstructing * the calldata heads and tails dynamically based on argument types. @@ -56,7 +55,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { */ function encode(DynamicCall memory dynamicCall, bytes[][] memory variables, uint256 variablesLength) external - view + pure override returns (bytes memory data) { @@ -75,7 +74,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { DynamicArg[] memory args, bytes[][] memory variables, uint256 variablesLength - ) internal view returns (bytes memory data) { + ) internal pure returns (bytes memory data) { uint256 n = args.length; bytes[] memory encodedArgs = new bytes[](n); bool[] memory isDynamic = new bool[](n); @@ -110,12 +109,11 @@ contract DynamicCallEncoder is IDynamicCallEncoder { */ function _encodeArg(DynamicArg memory arg, bytes[][] memory variables, uint256 variablesLength) internal - view + pure returns (EncodedArg memory out) { if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg.data); if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables, variablesLength); - if (arg.kind == DynamicArgKind.StaticCall) return _encodeStaticCall(arg.data, variables, variablesLength); revert DynamicCallEncoderStaticCallBadSpec(); } @@ -166,26 +164,9 @@ contract DynamicCallEncoder is IDynamicCallEncoder { out = _encodeFromAbiLikeBytes(variables[opIndex][subIndex]); } - /** - * @dev Encodes a staticcall argument - * Executes a staticcall and interprets the return data as an ABI value - */ - function _encodeStaticCall(bytes memory data, bytes[][] memory variables, uint256 variablesLength) - internal - view - returns (EncodedArg memory out) - { - if (data.length < 64) revert DynamicCallEncoderStaticCallBadSpec(); - DynamicStaticCallArg memory spec = abi.decode(data, (DynamicStaticCallArg)); - bytes memory callData = _buildCalldata(spec.selector, spec.arguments, variables, variablesLength); - (bool ok, bytes memory result) = spec.target.staticcall(callData); - if (!ok) revert DynamicCallEncoderStaticCallFailed(spec.target); - out = _encodeFromAbiLikeBytes(result); - } - /** * @dev Interprets ABI-like bytes as either a static or dynamic value - * Used for variable resolution and staticcall return values + * Used for variable resolution */ function _encodeFromAbiLikeBytes(bytes memory value) internal pure returns (EncodedArg memory out) { if (value.length < 32) revert DynamicCallEncoderVariableTooShort(); diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol index 3f19329..6a2724b 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol @@ -18,25 +18,10 @@ pragma solidity ^0.8.20; * @dev Kind of dynamic argument to be encoded * @param Literal ABI-encoded literal value provided by the resolver * @param Variable Reference to a previously resolved variable value - * @param StaticCall Result of executing a static call at encoding time */ enum DynamicArgKind { Literal, - Variable, - StaticCall -} - -/** - * @dev Specification for a static call whose return value - * will be used as an argument in another call - * @param target Contract to be called via staticcall - * @param selector Function selector to invoke - * @param arguments Arguments to be encoded and passed to the static call - */ -struct DynamicStaticCallArg { - address target; - bytes4 selector; - DynamicArg[] arguments; + Variable } /** diff --git a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol index 79e4a5c..06ab52a 100644 --- a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -67,11 +67,6 @@ interface IDynamicCallEncoder { */ error DynamicCallEncoderStaticCallBadSpec(); - /** - * @dev The staticcall execution failed - */ - error DynamicCallEncoderStaticCallFailed(address target); - /** * @dev Encodes a dynamic call into calldata. * @param dynamicCall Dynamic call specification. diff --git a/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol b/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol index 5192880..470002b 100644 --- a/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol +++ b/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol @@ -6,6 +6,7 @@ import './CallOperationsValidator.sol'; import './Safeguards.sol'; import './BaseOperationsValidator.sol'; import '../Intents.sol'; +import '../dynamic-calls/DynamicCallTypes.sol'; /** * @title DynamicCallOperationsValidator diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index cb43ed1..007c4a2 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -8,6 +8,7 @@ import { OpType, randomEvmAddress, randomHex, + randomNumber, randomSig, USD_ADDRESS, ZERO_ADDRESS, @@ -58,7 +59,6 @@ import { literal, Proposal, signProposal, - staticCall, SwapOperation, SwapProposal, toAddress, @@ -3083,7 +3083,8 @@ describe('Settler', () => { let proposal: Proposal let dynamicCallEncoder: DynamicCallEncoder - const argument = randomEvmAddress() + const arg0 = randomEvmAddress() + const arg1 = randomNumber(2) const value = fp(0.00001) const feeAmount = fp(0.01) const eventTopic = randomHex(32) @@ -3116,13 +3117,14 @@ describe('Settler', () => { { target, selector: target.interface.getFunction('returnAddress')!.selector, - arguments: [literal(['address'], [argument])], + arguments: [literal(['address'], [arg0])], value, }, { target, selector: target.interface.getFunction('returnUint')!.selector, - arguments: [staticCall(feeToken.target, feeToken.interface.getFunction('decimals')!.selector, [])], + arguments: [literal(['uint256'], [arg1])], + value: 0n, }, ], events: [{ topic: eventTopic, data: eventData }], @@ -3173,10 +3175,10 @@ describe('Settler', () => { expect(outputs).to.have.lengthOf(2) const [decodedA] = AbiCoder.defaultAbiCoder().decode(['address'], outputs[0]) - expect(decodedA.toLowerCase()).to.be.equal(argument) + expect(decodedA.toLowerCase()).to.be.equal(arg0) const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[1]) - expect(decodedB).to.be.equal(18) + expect(decodedB).to.be.equal(arg1) }) it('reverts if the dynamic call references a later operation output', async () => { @@ -3207,7 +3209,7 @@ describe('Settler', () => { }) }) - context('swap + dynamic call', () => { + context('swap + call + dynamic call', () => { let smartAccount: SmartAccount let tokenIn: TokenMock let tokenOutA: TokenMock, tokenOutB: TokenMock @@ -3250,6 +3252,12 @@ describe('Settler', () => { ], }) + const callOperation = createCallOperation({ + user: smartAccount, + chainId, + calls: [{ target: tokenOutA, data: tokenOutA.interface.encodeFunctionData('decimals') }], + }) + const dynamicCallOperation = createDynamicCallOperation({ user: smartAccount, chainId, @@ -3257,7 +3265,12 @@ describe('Settler', () => { { target, selector: target.interface.getFunction('returnUint')!.selector, - arguments: [variable(0, 1)], + arguments: [variable(0, 1)], // First operation, second output + }, + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [variable(1, 0)], // Second operation, first output }, ], events: [{ topic: eventTopic, data: eventData }], @@ -3267,7 +3280,7 @@ describe('Settler', () => { settler, feePayer: user, maxFees: [], - operations: [swapOperation, dynamicCallOperation], + operations: [swapOperation, callOperation, dynamicCallOperation], }) }) @@ -3285,10 +3298,10 @@ describe('Settler', () => { executorData, amountsOut: [swapAmountOutA, swapAmountOutB], }) - proposal.datas = [...proposal.datas, '0x'] + proposal.datas = [...proposal.datas, '0x', '0x'] }) - it('passes the swap output into the dynamic call', async () => { + it('passes the previous outputs into the dynamic call', async () => { const signature = await signProposal(settler, intent, solver, proposal, admin) const tx = await settler.execute(intent, proposal, signature) @@ -3300,16 +3313,22 @@ describe('Settler', () => { expect(events[0].args.data).to.be.equal(eventData) const [outputs] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[0].args.output) - expect(outputs).to.have.lengthOf(1) + expect(outputs).to.have.lengthOf(2) - const [decoded] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[0]) - expect(decoded).to.be.equal(swapAmountOutB) + const [decodedA] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[0]) + expect(decodedA).to.be.equal(swapAmountOutB) + + const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[1]) + expect(decodedB).to.be.equal(6n) const callEvents = await smartAccount.queryFilter(smartAccount.filters.Called(), tx.blockNumber) - expect(callEvents).to.have.lengthOf(1) - expect(callEvents[0].args.data).to.be.equal( + expect(callEvents).to.have.lengthOf(3) + + expect(callEvents[0].args.data).to.be.equal(tokenOutA.interface.encodeFunctionData('decimals')) + expect(callEvents[1].args.data).to.be.equal( target.interface.encodeFunctionData('returnUint', [swapAmountOutB]) ) + expect(callEvents[2].args.data).to.be.equal(target.interface.encodeFunctionData('returnUint', [6n])) }) }) diff --git a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts index 535412e..020a442 100644 --- a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -2,8 +2,8 @@ import { randomEvmAddress } from '@mimicprotocol/sdk' import { expect } from 'chai' import { network } from 'hardhat' -import { DynamicCallEncoder, StaticCallMock } from '../../types/ethers-contracts/index.js' -import { DynamicArg, literal, staticCall, variable } from '../helpers' +import { DynamicCallEncoder } from '../../types/ethers-contracts/index.js' +import { DynamicArg, literal, variable } from '../helpers' const { ethers } = await network.connect() @@ -164,78 +164,6 @@ describe('DynamicCallEncoder', () => { }) }) - context('with staticcall arguments', () => { - let mock: StaticCallMock - - beforeEach('deploy static call mock', async () => { - mock = await ethers.deployContract('StaticCallMock') - }) - - context('when the staticcall receives a literal', () => { - context('with fixed-length return types', () => { - it('encodes arguments properly', async () => { - const to = randomEvmAddress() - const amount = 999n - - const call = dynamicCall('transfer', [ - staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [ - literal(['address'], [to]), - ]), - literal(['uint256'], [amount]), - ]) - const encoded = await encoder.encode(call, [], 0) - expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, amount])) - }) - }) - - context('with arbitrary-length return types', () => { - it('encodes arguments properly', async () => { - const values = [1n, 2n, 3n] - - const call = dynamicCall('foo', [ - staticCall(mock.target, mock.interface.getFunction('returnArray')!.selector, [ - literal(['uint256[]'], [values]), - ]), - ]) - - const encoded = await encoder.encode(call, [], 0) - expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) - }) - }) - }) - - context('when the staticcall receives a variable', () => { - it('encodes arguments properly', async () => { - const owner = randomEvmAddress() - const variables = [[ethers.AbiCoder.defaultAbiCoder().encode(['address'], [owner])]] - - const call = dynamicCall('balanceOf', [ - staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [variable(0, 0)]), - ]) - - const encoded = await encoder.encode(call, variables, variables.length) - expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [owner])) - }) - }) - - context('when the staticcall receives the result of another staticcall', () => { - it('encodes arguments properly', async () => { - const to = randomEvmAddress() - - const call = dynamicCall('balanceOf', [ - staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [ - staticCall(mock.target, mock.interface.getFunction('returnAddress')!.selector, [ - literal(['address'], [to]), - ]), - ]), - ]) - - const encoded = await encoder.encode(call, [], 0) - expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [to])) - }) - }) - }) - context('when variables length exceeds the variables array length', () => { const variables = [[ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [1n])]] const call = dynamicCall('foo', [variable(0, 0)]) diff --git a/packages/evm/test/helpers/dynamic-calls.ts b/packages/evm/test/helpers/dynamic-calls.ts index 1cd84de..d820d99 100644 --- a/packages/evm/test/helpers/dynamic-calls.ts +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -12,11 +12,3 @@ export function variable(opIndex: number, subIndex: number): DynamicArg { const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [opIndex, subIndex]) return { kind: 1, data } } - -export function staticCall(target: string, selector: string, args: DynamicArg[]): DynamicArg { - const data = AbiCoder.defaultAbiCoder().encode( - ['tuple(address target, bytes4 selector, tuple(uint8 kind, bytes data)[] arguments)'], - [{ target, selector, arguments: args }] - ) - return { kind: 2, data } -} From 19f878ac81b6fb1415c1c12eb854d456846fc2c8 Mon Sep 17 00:00:00 2001 From: lgalende Date: Mon, 20 Apr 2026 12:26:30 -0300 Subject: [PATCH 15/21] evm: rename DynamicCallEncoder error --- packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol | 2 +- packages/evm/contracts/interfaces/IDynamicCallEncoder.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol index 521c20c..b190d39 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -114,7 +114,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { { if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg.data); if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables, variablesLength); - revert DynamicCallEncoderStaticCallBadSpec(); + revert DynamicCallEncoderInvalidArgKind(); } /** diff --git a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol index 06ab52a..29670fc 100644 --- a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -63,9 +63,9 @@ interface IDynamicCallEncoder { error DynamicCallEncoderVariableTooShort(); /** - * @dev The static call argument cannot be decoded + * @dev The argument kind is not valid */ - error DynamicCallEncoderStaticCallBadSpec(); + error DynamicCallEncoderInvalidArgKind(); /** * @dev Encodes a dynamic call into calldata. From aaa07afa1bd04fa8b4a0bad3208f87e6f0636bda Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 22 Apr 2026 14:41:42 -0300 Subject: [PATCH 16/21] evm: optimize SmartAccountsHandlerHelpers call --- .../SmartAccountsHandlerHelpers.sol | 9 ++-- .../test/dynamic-calls/StaticCallMock.sol | 13 ++++++ packages/evm/contracts/utils/BytesHelpers.sol | 4 +- .../SmartAccountsHandlerHelpers.test.ts | 42 +++++++++++++++---- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol b/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol index a17bde2..946e4eb 100644 --- a/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol +++ b/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol @@ -42,12 +42,15 @@ library SmartAccountsHandlerHelpers { */ function call(address handler, address account, address target, bytes memory data, uint256 value) internal - returns (bytes memory) + returns (bytes memory result) { - bytes memory result = Address.functionDelegateCall( + result = Address.functionDelegateCall( handler, abi.encodeWithSelector(ISmartAccountsHandler.call.selector, account, target, data, value) ); - return abi.decode(result, (bytes)); + // Skip the ABI-encoded offset and length of the outer `bytes` to point at the inner payload + assembly { + result := add(result, 64) + } } } diff --git a/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol index a6ae3e6..fad711e 100644 --- a/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol +++ b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol @@ -3,6 +3,11 @@ pragma solidity ^0.8.20; contract StaticCallMock { + struct StructMock { + uint256 a; + address b; + } + function returnUint(uint256 value) external pure returns (uint256) { return value; } @@ -14,4 +19,12 @@ contract StaticCallMock { function returnArray(uint256[] calldata value) external pure returns (uint256[] memory) { return value; } + + function returnFixedArray(uint256[3] calldata value) external pure returns (uint256[3] memory) { + return value; + } + + function returnStruct(StructMock calldata value) external pure returns (StructMock memory) { + return value; + } } diff --git a/packages/evm/contracts/utils/BytesHelpers.sol b/packages/evm/contracts/utils/BytesHelpers.sol index 4616046..66e1ce6 100644 --- a/packages/evm/contracts/utils/BytesHelpers.sol +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -19,7 +19,9 @@ pragma solidity ^0.8.20; * @dev Collection of low-level helpers to operate on `bytes` values in memory. */ library BytesHelpers { - /// @dev Thrown when a slice operation exceeds the bounds of the input bytes + /** + * @dev Thrown when a slice operation exceeds the bounds of the input bytes + */ error BytesLibSliceOutOfBounds(); /** diff --git a/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts b/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts index f6213e5..d98a9e8 100644 --- a/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts +++ b/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts @@ -1,3 +1,4 @@ +import { randomEvmAddress } from '@mimicprotocol/sdk' import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/types' import { expect } from 'chai' import { AbiCoder } from 'ethers' @@ -33,17 +34,42 @@ describe('SmartAccountsHandlerHelpers', () => { }) describe('call', () => { - it('returns the expected bytes', async () => { - const value = 11n - const data = target.interface.encodeFunctionData('returnUint', [value]) + const itReturnsTheExpectedBytes = ( + name: string, + args: unknown[], + types: string[], + expectedDecoded: unknown[] + ): void => { + it('returns the expected bytes', async () => { + const data = target.interface.encodeFunctionData(name, args) + const result = await helper.call.staticCall(handler, smartAccount, target, data, 0) + const expected = target.interface.encodeFunctionResult(name, args) - const result = await helper.call.staticCall(handler, smartAccount, target, data, 0) + expect(result).to.equal(expected) + expect(AbiCoder.defaultAbiCoder().decode(types, result)).to.deep.equal(expectedDecoded) + }) + } - const expected = target.interface.encodeFunctionResult('returnUint', [value]) - expect(result).to.equal(expected) + context('when returning a uint256', () => { + itReturnsTheExpectedBytes('returnUint', [11n], ['uint256'], [11n]) + }) + + context('when returning a dynamic array', () => { + const dynamicArray = [11n, 22n, 33n] + + itReturnsTheExpectedBytes('returnArray', [dynamicArray], ['uint256[]'], [dynamicArray]) + }) + + context('when returning a fixed-length array', () => { + const fixedArray = [44n, 55n, 66n] + + itReturnsTheExpectedBytes('returnFixedArray', [fixedArray], ['uint256[3]'], [fixedArray]) + }) + + context('when returning a struct', () => { + const struct = { a: 77n, b: randomEvmAddress() } - const [decoded] = AbiCoder.defaultAbiCoder().decode(['uint256'], result) - expect(decoded).to.equal(value) + itReturnsTheExpectedBytes('returnStruct', [struct], ['tuple(uint256 a,address b)'], [[struct.a, struct.b]]) }) }) }) From f87311b7a3579d0e73891f6d6919b4a521f105d2 Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 22 Apr 2026 15:26:22 -0300 Subject: [PATCH 17/21] test: add chained references and mixed kinds case --- packages/evm/test/Settler.test.ts | 50 ++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 007c4a2..3a26fc8 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -3209,7 +3209,7 @@ describe('Settler', () => { }) }) - context('swap + call + dynamic call', () => { + context('swap + call + dynamic call + dynamic call', () => { let smartAccount: SmartAccount let tokenIn: TokenMock let tokenOutA: TokenMock, tokenOutB: TokenMock @@ -3276,11 +3276,24 @@ describe('Settler', () => { events: [{ topic: eventTopic, data: eventData }], }) + const dynamicCallOperation2 = createDynamicCallOperation({ + user: smartAccount, + chainId, + calls: [ + { + target, + selector: target.interface.getFunction('returnStruct')!.selector, + arguments: [variable(2, 1), literal(['address'], [tokenOutA.target])], // Third operation, second output + literal + }, + ], + events: [{ topic: eventTopic, data: eventData }], + }) + intent = createIntent({ settler, feePayer: user, maxFees: [], - operations: [swapOperation, callOperation, dynamicCallOperation], + operations: [swapOperation, callOperation, dynamicCallOperation, dynamicCallOperation2], }) }) @@ -3298,37 +3311,54 @@ describe('Settler', () => { executorData, amountsOut: [swapAmountOutA, swapAmountOutB], }) - proposal.datas = [...proposal.datas, '0x', '0x'] + proposal.datas = [...proposal.datas, '0x', '0x', '0x'] }) - it('passes the previous outputs into the dynamic call', async () => { + it('passes the previous outputs into the dynamic calls', async () => { const signature = await signProposal(settler, intent, solver, proposal, admin) const tx = await settler.execute(intent, proposal, signature) const events = await settler.queryFilter(settler.filters.OperationExecuted(), tx.blockNumber) - expect(events).to.have.lengthOf(1) + expect(events).to.have.lengthOf(2) + // First dynamic call event expect(events[0].args.opType).to.be.equal(OpType.EvmDynamicCall) expect(events[0].args.topic).to.be.equal(eventTopic) expect(events[0].args.data).to.be.equal(eventData) - const [outputs] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[0].args.output) - expect(outputs).to.have.lengthOf(2) + const [outputs0] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[0].args.output) + expect(outputs0).to.have.lengthOf(2) - const [decodedA] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[0]) + const [decodedA] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs0[0]) expect(decodedA).to.be.equal(swapAmountOutB) - const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[1]) + const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs0[1]) expect(decodedB).to.be.equal(6n) + // Second dynamic call event + expect(events[1].args.opType).to.be.equal(OpType.EvmDynamicCall) + expect(events[1].args.topic).to.be.equal(eventTopic) + expect(events[1].args.data).to.be.equal(eventData) + + const [outputs1] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[1].args.output) + expect(outputs1).to.have.lengthOf(1) + + const [decodedStruct] = AbiCoder.defaultAbiCoder().decode(['tuple(uint256 a,address b)'], outputs1[0]) + expect(decodedStruct.a).to.be.equal(6n) + expect(decodedStruct.b).to.be.equal(tokenOutA.target) + + // All operations calls const callEvents = await smartAccount.queryFilter(smartAccount.filters.Called(), tx.blockNumber) - expect(callEvents).to.have.lengthOf(3) + expect(callEvents).to.have.lengthOf(4) expect(callEvents[0].args.data).to.be.equal(tokenOutA.interface.encodeFunctionData('decimals')) expect(callEvents[1].args.data).to.be.equal( target.interface.encodeFunctionData('returnUint', [swapAmountOutB]) ) expect(callEvents[2].args.data).to.be.equal(target.interface.encodeFunctionData('returnUint', [6n])) + expect(callEvents[3].args.data).to.be.equal( + target.interface.encodeFunctionData('returnStruct', [[6n, tokenOutA.target]]) + ) }) }) From aef09b00d206f0994da7653eeb4996531f77bf26 Mon Sep 17 00:00:00 2001 From: lgalende Date: Tue, 28 Apr 2026 12:09:56 -0300 Subject: [PATCH 18/21] evm: explicit dynamic attribute Co-authored-by: Facu Spagnuolo --- .../dynamic-calls/DynamicCallEncoder.sol | 104 ++++----------- .../dynamic-calls/DynamicCallTypes.sol | 2 + .../interfaces/IDynamicCallEncoder.sol | 22 +--- .../dynamic-calls/DynamicCallEncoder.test.ts | 121 +++++++++++++++++- packages/evm/test/helpers/dynamic-calls.ts | 12 +- .../evm/test/helpers/intents/dynamic-call.ts | 4 +- 6 files changed, 149 insertions(+), 116 deletions(-) diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol index b190d39..4a63610 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -34,16 +34,12 @@ contract DynamicCallEncoder is IDynamicCallEncoder { /** * @dev Internal representation of a fully-encoded argument - * @param data ABI-encoded argument payload: - * - static: inline ABI words - * - dynamic: tail data ([len][data...]) + * @param data ABI-encoded argument payload * @param isDynamic Whether this argument requires a head offset - * @param headLength Bytes contributed to the calldata head */ struct EncodedArg { bytes data; bool isDynamic; - uint256 headLength; } /** @@ -84,7 +80,7 @@ contract DynamicCallEncoder is IDynamicCallEncoder { EncodedArg memory enc = _encodeArg(args[i], variables, variablesLength); encodedArgs[i] = enc.data; isDynamic[i] = enc.isDynamic; - headLength += enc.headLength; + headLength += enc.isDynamic ? 32 : enc.data.length; } bytes memory heads; @@ -112,104 +108,50 @@ contract DynamicCallEncoder is IDynamicCallEncoder { pure returns (EncodedArg memory out) { - if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg.data); - if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg.data, variables, variablesLength); + if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg); + if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg, variables, variablesLength); revert DynamicCallEncoderInvalidArgKind(); } /** - * @dev Encodes a literal argument. It supports: - * - Static values encoded as [size][data][0] - * - Dynamic values pre-encoded with a dynamic ABI prefix + * @dev Encodes a literal argument */ - function _encodeLiteral(bytes memory argument) internal pure returns (EncodedArg memory out) { - if (argument.length % 32 != 0) revert DynamicCallEncoderBadLength(); - - if (_hasDynamicPrefix(argument)) { - // Dynamic literal: remove pre-encoding prefix - bytes memory encodedArg = argument.sliceFrom(96); - if (encodedArg.length == 0) revert DynamicCallEncoderEmptyDynamic(); - - out.data = encodedArg; - out.isDynamic = true; - out.headLength = 32; - } else { - // Static literal: [size][data][zero] - if (argument.length < 64) revert DynamicCallEncoderTooShortStatic(); - - uint256 staticSize = argument.readWord0(); - if (argument.length != staticSize + 32) revert DynamicCallEncoderBadStaticSize(); - if (!argument.lastWordIsZero()) revert DynamicCallEncoderBadStaticTrailer(); - - bytes memory encodedArg = argument.slice(32, argument.length - 32); - out.data = encodedArg; - out.isDynamic = false; - out.headLength = encodedArg.length; - } + function _encodeLiteral(DynamicArg memory arg) internal pure returns (EncodedArg memory) { + return _encodeAbiValue(arg.data, arg.isDynamic); } /** * @dev Encodes a variable argument by resolving it from the variables list */ - function _encodeVariable(bytes memory data, bytes[][] memory variables, uint256 variablesLength) + function _encodeVariable(DynamicArg memory arg, bytes[][] memory variables, uint256 variablesLength) internal pure - returns (EncodedArg memory out) + returns (EncodedArg memory) { - if (data.length != 64) revert DynamicCallEncoderVariableRefBadLength(); - uint256 opIndex = data.readWord0(); - uint256 subIndex = data.readWord1(); + if (arg.data.length != 64) revert DynamicCallEncoderVariableRefBadLength(); + uint256 opIndex = arg.data.readWord0(); + uint256 subIndex = arg.data.readWord1(); if (opIndex >= variablesLength) revert DynamicCallEncoderVariableOutOfBounds(); if (subIndex >= variables[opIndex].length) revert DynamicCallEncoderVariableOutOfBounds(); - out = _encodeFromAbiLikeBytes(variables[opIndex][subIndex]); + + return _encodeAbiValue(variables[opIndex][subIndex], arg.isDynamic); } /** - * @dev Interprets ABI-like bytes as either a static or dynamic value - * Used for variable resolution + * @dev Interprets ABI-like bytes as either a static or dynamic value. Used for variable resolution. */ - function _encodeFromAbiLikeBytes(bytes memory value) internal pure returns (EncodedArg memory out) { - if (value.length < 32) revert DynamicCallEncoderVariableTooShort(); + function _encodeAbiValue(bytes memory data, bool isDynamic) internal pure returns (EncodedArg memory out) { + if (data.length == 0 || data.length % 32 != 0) revert DynamicCallEncoderBadLength(); + + if (isDynamic) { + if (data.length < 64) revert DynamicCallEncoderEmptyDynamic(); + if (data.readWord0() != 0x20) revert DynamicCallEncoderBadLength(); - if (_looksLikeSingleDynamicAbiValue(value)) { - bytes memory tail = value.sliceFrom(32); - if (tail.length == 0) revert DynamicCallEncoderEmptyDynamic(); - out.data = tail; + out.data = data.sliceFrom(32); out.isDynamic = true; - out.headLength = 32; } else { - out.data = value.slice(0, 32); + out.data = data; out.isDynamic = false; - out.headLength = 32; } } - - /** - * @dev Detects ABI encoding of a single dynamic return value - */ - function _looksLikeSingleDynamicAbiValue(bytes memory data) private pure returns (bool) { - if (data.length < 64) return false; - if (data.length % 32 != 0) return false; - return data.readWord0() == 0x20; - } - - /** - * @dev Detects the dynamic pre-encoding prefix used by abi.encode("", value) - */ - function _hasDynamicPrefix(bytes memory argument) private pure returns (bool) { - if (argument.length < 96) return false; - - bytes32 w0; - bytes32 w1; - bytes32 w2; - - assembly { - let off := add(argument, 32) - w0 := mload(off) - w1 := mload(add(off, 32)) - w2 := mload(add(off, 64)) - } - - return (uint256(w0) == 0x40) && (uint256(w1) == 0x60) && (w2 == bytes32(0)); - } } diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol index 6a2724b..eaaae1c 100644 --- a/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol +++ b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol @@ -28,10 +28,12 @@ enum DynamicArgKind { * @dev Represents a single dynamic argument * @param kind Type of argument resolution strategy * @param data Encoded argument data, interpreted based on `kind` + * @param isDynamic Whether the resolved argument is ABI-dynamic */ struct DynamicArg { DynamicArgKind kind; bytes data; + bool isDynamic; } /** diff --git a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol index 29670fc..de2e7f9 100644 --- a/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -27,21 +27,6 @@ interface IDynamicCallEncoder { */ error DynamicCallEncoderEmptyDynamic(); - /** - * @dev The static literal has an invalid size prefix - */ - error DynamicCallEncoderBadStaticSize(); - - /** - * @dev The static literal does not end with a zero word - */ - error DynamicCallEncoderBadStaticTrailer(); - - /** - * @dev The static literal is too short to be valid - */ - error DynamicCallEncoderTooShortStatic(); - /** * @dev The variable reference is not exactly one word */ @@ -57,11 +42,6 @@ interface IDynamicCallEncoder { */ error DynamicCallEncoderVariablesLengthOutOfBounds(); - /** - * @dev The variable value is too short to be interpreted - */ - error DynamicCallEncoderVariableTooShort(); - /** * @dev The argument kind is not valid */ @@ -75,6 +55,6 @@ interface IDynamicCallEncoder { */ function encode(DynamicCall memory dynamicCall, bytes[][] memory variables, uint256 variablesLength) external - view + pure returns (bytes memory); } diff --git a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts index 020a442..f0c1689 100644 --- a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -21,6 +21,11 @@ describe('DynamicCallEncoder', () => { 'function balanceOf(address) view returns (uint256)', 'function transfer(address,uint256) returns (bool)', 'function foo(uint256[])', + 'function number(uint256 value) view returns (uint256)', + 'function bar(uint256[2])', + 'function baz((uint256,address))', + 'function qux(uint256,uint256[2])', + 'function nested((uint256[],uint256[2],(address,uint256)[])[])', ]) function dynamicCall(method: string, args: DynamicArg[]) { @@ -59,13 +64,55 @@ describe('DynamicCallEncoder', () => { context('with arbitrary-length arguments', () => { const values = [1n, 2n, 3n] - const call = dynamicCall('foo', [literal(['uint256[]'], [values])]) + const call = dynamicCall('foo', [literal(['uint256[]'], [values], true)]) it('encodes arguments properly', async () => { const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) }) }) + + context('when a static uint256 equals an ABI dynamic offset', () => { + const value = 96n + const call = dynamicCall('number', [literal(['uint256'], [value])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('number', [value])) + }) + }) + + context('when a static address equals an ABI dynamic offset', () => { + const value = '0x0000000000000000000000000000000000000060' + const call = dynamicCall('balanceOf', [literal(['address'], [value])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [value])) + }) + }) + + context('with an array of structs containing nested arrays', () => { + const values = [ + [ + [1n, 2n, 3n], + [4n, 5n], + [ + [randomEvmAddress(), 6n], + [randomEvmAddress(), 7n], + ], + ], + [[8n, 9n], [10n, 11n], [[randomEvmAddress(), 12n]]], + ] + const call = dynamicCall('nested', [ + literal(['tuple(uint256[],uint256[2],tuple(address,uint256)[])[]'], [values], true), + ]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('nested', [values])) + }) + }) }) context('with variable arguments', () => { @@ -73,6 +120,20 @@ describe('DynamicCallEncoder', () => { const var0 = 100n const var1 = randomEvmAddress() const var2 = [1, 2, 3, 4, 5, 6, 7] + const var3 = [11n, 22n] + const var4 = [33n, randomEvmAddress()] + const var5 = [32n, 99n] + const var6 = [ + [ + [1n, 2n, 3n], + [4n, 5n], + [ + [randomEvmAddress(), 6n], + [randomEvmAddress(), 7n], + ], + ], + [[], [10n, 11n], [[randomEvmAddress(), 12n]]], + ] // variables[opIndex][subIndex] const variables = [ @@ -80,7 +141,16 @@ describe('DynamicCallEncoder', () => { ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [var0]), ethers.AbiCoder.defaultAbiCoder().encode(['address'], [var1]), ], - [ethers.AbiCoder.defaultAbiCoder().encode(['uint256[]'], [var2])], + [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[]'], [var2]), + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[2]'], [var3]), + ethers.AbiCoder.defaultAbiCoder().encode(['tuple(uint256,address)'], [var4]), + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[2]'], [var5]), + ethers.AbiCoder.defaultAbiCoder().encode( + ['tuple(uint256[],uint256[2],tuple(address,uint256)[])[]'], + [var6] + ), + ], ] context('with a single argument', () => { @@ -103,18 +173,55 @@ describe('DynamicCallEncoder', () => { }) context('with arbitrary-length arguments', () => { - const call = dynamicCall('foo', [variable(1, 0)]) + const call = dynamicCall('foo', [variable(1, 0, true)]) it('encodes arguments properly', async () => { const encoded = await encoder.encode(call, variables, variables.length) expect(encoded).to.equal(iface.encodeFunctionData('foo', [var2])) }) }) + + context('with multi-word static arguments', () => { + const call = dynamicCall('bar', [variable(1, 1)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('bar', [var3])) + }) + }) + + context('with static tuple arguments', () => { + const call = dynamicCall('baz', [variable(1, 2)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('baz', [var4])) + }) + }) + + context('when a static value starts with an ABI dynamic offset', () => { + const value = 1n + const call = dynamicCall('qux', [literal(['uint256'], [value]), variable(1, 3)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('qux', [value, var5])) + }) + }) + + context('with an array of structs containing nested arrays', () => { + const call = dynamicCall('nested', [variable(1, 4, true)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('nested', [var6])) + }) + }) }) context('when the variable spec is invalid', () => { context('when variable ref is not 64 bytes', () => { - const call = dynamicCall('foo', [{ kind: 1, data: '0x11' }]) + const call = dynamicCall('foo', [{ kind: 1, data: '0x11', isDynamic: false }]) it('reverts with DynamicCallEncoderVariableRefBadLength', async () => { await expect(encoder.encode(call, [], 0)).to.be.revertedWithCustomError( @@ -150,14 +257,14 @@ describe('DynamicCallEncoder', () => { }) }) - context('when variable bytes are too short to be static', () => { + context('when variable bytes are not word-aligned', () => { const variables = [['0x1234']] const call = dynamicCall('transfer', [literal(['address'], [randomEvmAddress()]), variable(0, 0)]) - it('reverts with DynamicCallEncoderVariableTooShort', async () => { + it('reverts with DynamicCallEncoderBadLength', async () => { await expect(encoder.encode(call, variables, variables.length)).to.be.revertedWithCustomError( encoder, - 'DynamicCallEncoderVariableTooShort' + 'DynamicCallEncoderBadLength' ) }) }) diff --git a/packages/evm/test/helpers/dynamic-calls.ts b/packages/evm/test/helpers/dynamic-calls.ts index d820d99..9170601 100644 --- a/packages/evm/test/helpers/dynamic-calls.ts +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -1,14 +1,14 @@ import { AbiCoder } from 'ethers' -export type DynamicArg = { kind: number; data: string } +export type DynamicArg = { kind: number; data: string; isDynamic: boolean } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function literal(types: string[], values: any[]): DynamicArg { - const data = AbiCoder.defaultAbiCoder().encode(['string', ...types], ['', ...values]) - return { kind: 0, data } +export function literal(types: string[], values: any[], isDynamic = false): DynamicArg { + const data = AbiCoder.defaultAbiCoder().encode(types, values) + return { kind: 0, data, isDynamic } } -export function variable(opIndex: number, subIndex: number): DynamicArg { +export function variable(opIndex: number, subIndex: number, isDynamic = false): DynamicArg { const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [opIndex, subIndex]) - return { kind: 1, data } + return { kind: 1, data, isDynamic } } diff --git a/packages/evm/test/helpers/intents/dynamic-call.ts b/packages/evm/test/helpers/intents/dynamic-call.ts index 96a0cc1..30921c2 100644 --- a/packages/evm/test/helpers/intents/dynamic-call.ts +++ b/packages/evm/test/helpers/intents/dynamic-call.ts @@ -53,7 +53,9 @@ function getDefaults(): Partial { function encodeDynamicCallData(call: DynamicCallData): string { return AbiCoder.defaultAbiCoder().encode( - ['tuple(address target, uint256 value, bytes4 selector, tuple(uint8 kind, bytes data)[] arguments)'], + [ + 'tuple(address target, uint256 value, bytes4 selector, tuple(uint8 kind, bytes data, bool isDynamic)[] arguments)', + ], [ { target: toAddress(call.target), From ed275cbd76589937cb725ac0cb2f783267582dde Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 29 Apr 2026 02:13:49 -0300 Subject: [PATCH 19/21] evm: add readWord, use mcopy, and remove lastWordIsZero --- .../contracts/test/utils/BytesHelpersMock.sol | 6 +--- packages/evm/contracts/utils/BytesHelpers.sol | 36 +++++++------------ packages/evm/test/utils/BytesHelpers.test.ts | 21 ----------- 3 files changed, 13 insertions(+), 50 deletions(-) diff --git a/packages/evm/contracts/test/utils/BytesHelpersMock.sol b/packages/evm/contracts/test/utils/BytesHelpersMock.sol index c956f06..cd8d453 100644 --- a/packages/evm/contracts/test/utils/BytesHelpersMock.sol +++ b/packages/evm/contracts/test/utils/BytesHelpersMock.sol @@ -12,7 +12,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import '../../utils/BytesHelpers.sol'; @@ -27,10 +27,6 @@ contract BytesHelpersMock { return data.readWord1(); } - function lastWordIsZero(bytes memory data) external pure returns (bool) { - return data.lastWordIsZero(); - } - function slice(bytes memory data, uint256 start, uint256 end) external pure returns (bytes memory) { return data.slice(start, end); } diff --git a/packages/evm/contracts/utils/BytesHelpers.sol b/packages/evm/contracts/utils/BytesHelpers.sol index 66e1ce6..61bfd0f 100644 --- a/packages/evm/contracts/utils/BytesHelpers.sol +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -12,7 +12,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; /** * @title BytesHelpers @@ -29,10 +29,8 @@ library BytesHelpers { * @param data Bytes array to read from * @return result First ABI word of `data` */ - function readWord0(bytes memory data) internal pure returns (uint256 result) { - assembly { - result := mload(add(data, 32)) - } + function readWord0(bytes memory data) internal pure returns (uint256) { + return readWord(data, 0); } /** @@ -40,24 +38,20 @@ library BytesHelpers { * @param data Bytes array to read from * @return result Second ABI word of `data` */ - function readWord1(bytes memory data) internal pure returns (uint256 result) { - assembly { - result := mload(add(data, 64)) - } + function readWord1(bytes memory data) internal pure returns (uint256) { + return readWord(data, 1); } /** - * @dev Checks whether the last 32-byte word of a bytes array is zero - * - * Commonly used to validate ABI-encoded static values, which must - * end with a zero padding word. + * @dev Reads the N-th 32-byte word of a bytes array + * @param data Bytes array to read from + * @param index Word index to read (0-based) + * @return result N-th ABI word of `data` */ - function lastWordIsZero(bytes memory data) internal pure returns (bool) { - bytes32 last; + function readWord(bytes memory data, uint256 index) private pure returns (uint256 result) { assembly { - last := mload(add(data, mload(data))) + result := mload(add(data, add(32, mul(index, 32)))) } - return last == bytes32(0); } /** @@ -76,13 +70,7 @@ library BytesHelpers { assembly { let src := add(add(data, 32), start) let dst := add(out, 32) - for { - let i := 0 - } lt(i, len) { - i := add(i, 32) - } { - mstore(add(dst, i), mload(add(src, i))) - } + mcopy(dst, src, len) } } diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts index 827064e..6ca2db2 100644 --- a/packages/evm/test/utils/BytesHelpers.test.ts +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -59,27 +59,6 @@ describe('BytesHelpers', () => { }) }) - describe('lastWordIsZero', () => { - context('when the last word is zero', () => { - const data = ethers.concat([AbiCoder.defaultAbiCoder().encode(['uint256'], [1n]), ethers.ZeroHash]) - - it('returns true', async () => { - expect(await library.lastWordIsZero(data)).to.equal(true) - }) - }) - - context('when the last word is not zero', () => { - const data = ethers.concat([ - AbiCoder.defaultAbiCoder().encode(['uint256'], [1n]), - AbiCoder.defaultAbiCoder().encode(['uint256'], [2n]), - ]) - - it('returns false', async () => { - expect(await library.lastWordIsZero(data)).to.equal(false) - }) - }) - }) - describe('slice(bytes)', () => { const data = '0x00112233445566778899aabbccddeeff' From d74796fb0f0cbe678cfb8cb3a797b0099176bf1b Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 29 Apr 2026 11:57:20 -0300 Subject: [PATCH 20/21] chore: upgrade sdk to v0.0.2-rc.1 --- packages/evm/package.json | 2 +- yarn.lock | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/evm/package.json b/packages/evm/package.json index 944a9df..a57fe15 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -17,7 +17,7 @@ "@openzeppelin/contracts": "5.3.0" }, "devDependencies": { - "@mimicprotocol/sdk": "0.0.1-rc.20", + "@mimicprotocol/sdk": "0.0.2-rc.1", "@nomicfoundation/hardhat-ethers": "^4.0.0-next.23", "@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.0-next.23", "@nomicfoundation/hardhat-ignition": "^3.0.0-next.23", diff --git a/yarn.lock b/yarn.lock index 70bafcf..e57e304 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,7 +48,7 @@ resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.31.1.tgz#d635cbac2533973ae6bfb5d3ba1de89ce5aece2d" integrity sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ== -"@coral-xyz/anchor@^0.32.1": +"@coral-xyz/anchor@0.32.1", "@coral-xyz/anchor@^0.32.1": version "0.32.1" resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.32.1.tgz#a07440d9d267840f4f99f1493bd8ce7d7f128e57" integrity sha512-zAyxFtfeje2FbMA1wzgcdVs7Hng/MijPKpRijoySPCicnvcTQs/+dnPZ/cR+LcXM9v9UYSyW81uRNYZtN5G4yg== @@ -482,11 +482,12 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" -"@mimicprotocol/sdk@0.0.1-rc.20": - version "0.0.1-rc.20" - resolved "https://registry.yarnpkg.com/@mimicprotocol/sdk/-/sdk-0.0.1-rc.20.tgz#a2a29c53b17901c38c25e39304ddcf46a72058f2" - integrity sha512-Hdw9jvyVI3ox6SbY/AI22yi55gDlf1Qx33UHdN1jCR+IL350rHBKO2b+L2zvSYIH4mcJWhXAofREM4zwKuqVBw== +"@mimicprotocol/sdk@0.0.2-rc.1": + version "0.0.2-rc.1" + resolved "https://registry.yarnpkg.com/@mimicprotocol/sdk/-/sdk-0.0.2-rc.1.tgz#2d8f69a006709c15631ae60e9ec437be9ec178ab" + integrity sha512-mGuBKp4JCdJh0b/awozp1qUULYdbRmsXdmVuwbT1ZZfVnZmlSpANqkiA2PGPUB8VEdQ9NvhgWhZcFZJzHy2exQ== dependencies: + "@coral-xyz/anchor" "0.32.1" "@solana/web3.js" "^1.98.4" borsh "^2.0.0" cron-parser "^5.3.1" From 57ea8b3d573ab4cfdadaeba74fac002ec46a31df Mon Sep 17 00:00:00 2001 From: Agustincito Date: Thu, 30 Apr 2026 11:35:57 -0300 Subject: [PATCH 21/21] SDK: Bump version --- packages/evm/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evm/package.json b/packages/evm/package.json index a57fe15..5fa448d 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -17,7 +17,7 @@ "@openzeppelin/contracts": "5.3.0" }, "devDependencies": { - "@mimicprotocol/sdk": "0.0.2-rc.1", + "@mimicprotocol/sdk": "~0.1.0", "@nomicfoundation/hardhat-ethers": "^4.0.0-next.23", "@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.0-next.23", "@nomicfoundation/hardhat-ignition": "^3.0.0-next.23",