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.
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/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/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'
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'
+ )
+ })
+ })
+ })
+})