diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index e63dd86..ac44c97 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -2,18 +2,22 @@ pragma solidity ^0.8.20; +import './dynamic-calls/DynamicCallTypes.sol'; + /** * @dev Enum representing the operation type. * - Swap: Swap tokens in the same chain. * - Transfer: Transfer tokens to one or more recipients. * - Call: Execute arbitrary contract calls. * - CrossChainSwap: Swap tokens between chains. + * - DynamicCall: Execute arbitrary dynamic contract calls. */ enum OpType { Swap, Transfer, Call, - CrossChainSwap + CrossChainSwap, + DynamicCall } /** @@ -162,6 +166,16 @@ struct CallData { uint256 value; } +/** + * @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 ABI-encoded low-level dynamic contract calls to be executed. + */ +struct DynamicCallOperation { + uint256 chainId; + bytes[] 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/Settler.sol b/packages/evm/contracts/Settler.sol index f7dd29a..e2e5ad9 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -23,7 +23,9 @@ 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'; @@ -53,6 +55,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 +81,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()); } /** @@ -145,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 @@ -199,25 +213,46 @@ 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)); + 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); + 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 outputs) + { Operation memory operation = intent.operations[index]; SwapOperation memory swapOperation = abi.decode(operation.data, (SwapOperation)); SwapProposal memory swapProposal = abi.decode(proposal.datas[index], (SwapProposal)); @@ -237,22 +272,24 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { uint256[] memory preBalancesOut = _getTokensOutBalance(swapOperation); IExecutor(swapProposal.executor).execute(intent, proposal, index); + outputs = new bytes[](swapOperation.tokensOut.length); if (swapOperation.destinationChain == block.chainid) { - uint256[] memory outputs = new uint256[](swapOperation.tokensOut.length); + 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)); } } @@ -262,7 +299,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 outputs) + { Operation memory operation = intent.operations[index]; TransferOperation memory transferOperation = abi.decode(operation.data, (TransferOperation)); _validateTransferOperation(transferOperation, proposal.datas[index]); @@ -273,6 +313,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _transferFrom(transfer.token, operation.user, transfer.recipient, transfer.amount, isSmartAccount); } + outputs = new bytes[](0); _emitOperationEvents(operation, proposal, intent.hash(), index, new bytes(0)); } @@ -282,12 +323,15 @@ 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 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 @@ -297,6 +341,34 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { _emitOperationEvents(operation, proposal, intent.hash(), index, abi.encode(outputs)); } + /** + * @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 operations outputs + */ + function _executeDynamicCall( + Intent memory intent, + Proposal memory proposal, + uint256 index, + 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); + + 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, index); + // 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)); + } + /** * @dev Validates an intent and its corresponding proposal The off-chain validators are assuring that: @@ -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 @@ -556,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/dynamic-calls/DynamicCallEncoder.sol b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol new file mode 100644 index 0000000..58ef08a --- /dev/null +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -0,0 +1,234 @@ +// 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 '../interfaces/IDynamicCallEncoder.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 is IDynamicCallEncoder { + using BytesHelpers for bytes; + + /** + * @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 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, uint256 variablesLength) + external + view + override + returns (bytes memory data) + { + if (variablesLength > variables.length) revert DynamicCallEncoderVariablesLengthOutOfBounds(); + data = _buildCalldata(dynamicCall.selector, dynamicCall.arguments, variables, variablesLength); + } + + /** + * @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, + 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, variablesLength); + 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, 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, variablesLength); + if (arg.kind == DynamicArgKind.StaticCall) return _encodeStaticCall(arg.data, variables, variablesLength); + 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, uint256 variablesLength) + internal + pure + returns (EncodedArg memory out) + { + if (data.length != 64) revert DynamicCallEncoderVariableRefBadLength(); + uint256 opIndex = data.readWord0(); + uint256 subIndex = data.readWord1(); + if (opIndex >= variablesLength) 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, 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 + */ + 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/interfaces/IDynamicCallEncoder.sol b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol new file mode 100644 index 0000000..79e4a5c --- /dev/null +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -0,0 +1,85 @@ +// 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 declared variables length exceeds the variables array length + */ + error DynamicCallEncoderVariablesLengthOutOfBounds(); + + /** + * @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 List of resolved variable values. + * @param variablesLength Number of resolved variables. + */ + function encode(DynamicCall memory dynamicCall, bytes[][] memory variables, uint256 variablesLength) + external + view + returns (bytes memory); +} diff --git a/packages/evm/contracts/interfaces/ISettler.sol b/packages/evm/contracts/interfaces/ISettler.sol index efc46de..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 */ @@ -214,6 +224,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 @@ -263,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/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 342095f..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,6 +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 _isDynamicCallOperationValid(operation, safeguard); revert OperationsValidatorUnknownOperationType(uint8(operation.opType)); } 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/dynamic-calls/StaticCallMock.sol b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol new file mode 100644 index 0000000..a6ae3e6 --- /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 payable returns (address) { + return value; + } + + function returnArray(uint256[] calldata value) external pure returns (uint256[] memory) { + return value; + } +} 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/contracts/test/utils/BytesHelpersMock.sol b/packages/evm/contracts/test/utils/BytesHelpersMock.sol new file mode 100644 index 0000000..c956f06 --- /dev/null +++ b/packages/evm/contracts/test/utils/BytesHelpersMock.sol @@ -0,0 +1,41 @@ +// 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 readWord1(bytes memory data) external pure returns (uint256) { + 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); + } + + 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..4616046 --- /dev/null +++ b/packages/evm/contracts/utils/BytesHelpers.sol @@ -0,0 +1,95 @@ +// 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 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 + * + * 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/Settler.test.ts b/packages/evm/test/Settler.test.ts index 2c1fd3b..cb43ed1 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' @@ -38,6 +39,9 @@ import { createCallProposal, createCrossChainSwapIntent, createCrossChainSwapOperation, + createDynamicCallIntent, + createDynamicCallOperation, + createDynamicCallProposal, createIntent, createProposal, createSwapIntent, @@ -47,23 +51,28 @@ 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' 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 @@ -96,6 +105,10 @@ describe('Settler', () => { it('has a smart accounts handler', async () => { expect(await settler.smartAccountsHandler()).to.not.be.equal(ZERO_ADDRESS) }) + + it('has a dynamic call decoder', async () => { + expect(await settler.dynamicCallEncoder()).to.not.be.equal(ZERO_ADDRESS) + }) }) describe('ownable', () => { @@ -342,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) @@ -1055,6 +1116,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 +3076,243 @@ describe('Settler', () => { }) }) + context('dynamic call', () => { + let user: SmartAccount + 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) + + 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('fund smart account', async () => { + await owner.sendTransaction({ to: user, value }) + }) + + 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])], + value, + }, + { + 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] }) + }) + + 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) + + 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) + + const postTargetBalance = await balanceOf(NATIVE_TOKEN_ADDRESS, target) + expect(postTargetBalance - preTargetBalance).to.be.eq(value) + }) + + 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(OpType.EvmDynamicCall) + 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) + }) + + 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', () => { + 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, 1)], + }, + ], + 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(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(1) + + const [decoded] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[0]) + 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', [swapAmountOutB]) + ) + }) + }) + context('one of each', () => { let target: Account, data: string let smartAccount: SmartAccount diff --git a/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts new file mode 100644 index 0000000..535412e --- /dev/null +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -0,0 +1,251 @@ +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 */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +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, variables.length) + 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, variables.length) + 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, variables.length) + 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] + + // variables[opIndex][subIndex] + 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(0, 1)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [var1])) + }) + }) + + context('with multiple arguments', () => { + const to = randomEvmAddress() + const call = dynamicCall('transfer', [literal(['address'], [to]), variable(0, 0)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('transfer', [to, var0])) + }) + }) + + context('with arbitrary-length arguments', () => { + const call = dynamicCall('foo', [variable(1, 0)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('foo', [var2])) + }) + }) + }) + + context('when the variable spec is invalid', () => { + context('when variable ref is not 64 bytes', () => { + const call = dynamicCall('foo', [{ kind: 1, data: '0x11' }]) + + it('reverts with DynamicCallEncoderVariableRefBadLength', async () => { + await expect(encoder.encode(call, [], 0)).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariableRefBadLength' + ) + }) + }) + + context('when operation index is out of bounds', () => { + 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, variables, variablesLength)).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderVariableOutOfBounds' + ) + }) + }) + + 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, variables.length)).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, 0)]) + + it('reverts with DynamicCallEncoderVariableTooShort', async () => { + await expect(encoder.encode(call, variables, variables.length)).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, [], 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)]) + + 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/helpers/dynamic-calls.ts b/packages/evm/test/helpers/dynamic-calls.ts new file mode 100644 index 0000000..1cd84de --- /dev/null +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -0,0 +1,22 @@ +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 } +} + +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 } +} 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/helpers/intents/dynamic-call.ts b/packages/evm/test/helpers/intents/dynamic-call.ts new file mode 100644 index 0000000..96a0cc1 --- /dev/null +++ b/packages/evm/test/helpers/intents/dynamic-call.ts @@ -0,0 +1,66 @@ +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 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: OpType.EvmDynamicCall }) + 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' 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', () => { 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) + }) + }) +}) diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts new file mode 100644 index 0000000..827064e --- /dev/null +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -0,0 +1,164 @@ +import { expect } from 'chai' +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: BytesHelpersMock + + 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('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]) + + 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' + + 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' + ) + }) + }) + }) +})