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