diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index e63dd86..455e84d 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -8,12 +8,14 @@ pragma solidity ^0.8.20; * - 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 +164,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..4a63610 --- /dev/null +++ b/packages/evm/contracts/dynamic-calls/DynamicCallEncoder.sol @@ -0,0 +1,157 @@ +// 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 + * + * 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 + * @param isDynamic Whether this argument requires a head offset + */ + struct EncodedArg { + bytes data; + bool isDynamic; + } + + /** + * @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 + pure + 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 pure 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.isDynamic ? 32 : enc.data.length; + } + + 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 + pure + returns (EncodedArg memory out) + { + if (arg.kind == DynamicArgKind.Literal) return _encodeLiteral(arg); + if (arg.kind == DynamicArgKind.Variable) return _encodeVariable(arg, variables, variablesLength); + revert DynamicCallEncoderInvalidArgKind(); + } + + /** + * @dev Encodes a literal argument + */ + function _encodeLiteral(DynamicArg memory arg) internal pure returns (EncodedArg memory) { + return _encodeAbiValue(arg.data, arg.isDynamic); + } + + /** + * @dev Encodes a variable argument by resolving it from the variables list + */ + function _encodeVariable(DynamicArg memory arg, bytes[][] memory variables, uint256 variablesLength) + internal + pure + returns (EncodedArg memory) + { + if (arg.data.length != 64) revert DynamicCallEncoderVariableRefBadLength(); + uint256 opIndex = arg.data.readWord0(); + uint256 subIndex = arg.data.readWord1(); + if (opIndex >= variablesLength) revert DynamicCallEncoderVariableOutOfBounds(); + if (subIndex >= variables[opIndex].length) revert DynamicCallEncoderVariableOutOfBounds(); + + return _encodeAbiValue(variables[opIndex][subIndex], arg.isDynamic); + } + + /** + * @dev Interprets ABI-like bytes as either a static or dynamic value. Used for variable resolution. + */ + function _encodeAbiValue(bytes memory data, bool isDynamic) internal pure returns (EncodedArg memory out) { + if (data.length == 0 || data.length % 32 != 0) revert DynamicCallEncoderBadLength(); + + if (isDynamic) { + if (data.length < 64) revert DynamicCallEncoderEmptyDynamic(); + if (data.readWord0() != 0x20) revert DynamicCallEncoderBadLength(); + + out.data = data.sliceFrom(32); + out.isDynamic = true; + } else { + out.data = data; + out.isDynamic = false; + } + } +} diff --git a/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol new file mode 100644 index 0000000..eaaae1c --- /dev/null +++ b/packages/evm/contracts/dynamic-calls/DynamicCallTypes.sol @@ -0,0 +1,51 @@ +// 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 + */ +enum DynamicArgKind { + Literal, + Variable +} + +/** + * @dev Represents a single dynamic argument + * @param kind Type of argument resolution strategy + * @param data Encoded argument data, interpreted based on `kind` + * @param isDynamic Whether the resolved argument is ABI-dynamic + */ +struct DynamicArg { + DynamicArgKind kind; + bytes data; + bool isDynamic; +} + +/** + * @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..de2e7f9 --- /dev/null +++ b/packages/evm/contracts/interfaces/IDynamicCallEncoder.sol @@ -0,0 +1,60 @@ +// 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 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 argument kind is not valid + */ + error DynamicCallEncoderInvalidArgKind(); + + /** + * @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 + pure + 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..470002b --- /dev/null +++ b/packages/evm/contracts/safeguards/DynamicCallOperationsValidator.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import './CallOperationsValidator.sol'; +import './Safeguards.sol'; +import './BaseOperationsValidator.sol'; +import '../Intents.sol'; +import '../dynamic-calls/DynamicCallTypes.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..946e4eb 100644 --- a/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol +++ b/packages/evm/contracts/smart-accounts/SmartAccountsHandlerHelpers.sol @@ -42,12 +42,15 @@ library SmartAccountsHandlerHelpers { */ function call(address handler, address account, address target, bytes memory data, uint256 value) internal - returns (bytes memory) + returns (bytes memory result) { - return - Address.functionDelegateCall( - handler, - abi.encodeWithSelector(ISmartAccountsHandler.call.selector, account, target, data, value) - ); + result = Address.functionDelegateCall( + handler, + abi.encodeWithSelector(ISmartAccountsHandler.call.selector, account, target, data, value) + ); + // Skip the ABI-encoded offset and length of the outer `bytes` to point at the inner payload + assembly { + result := add(result, 64) + } } } diff --git a/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol new file mode 100644 index 0000000..fad711e --- /dev/null +++ b/packages/evm/contracts/test/dynamic-calls/StaticCallMock.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +contract StaticCallMock { + struct StructMock { + uint256 a; + address b; + } + + 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; + } + + function returnFixedArray(uint256[3] calldata value) external pure returns (uint256[3] memory) { + return value; + } + + function returnStruct(StructMock calldata value) external pure returns (StructMock memory) { + return value; + } +} diff --git a/packages/evm/contracts/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..cd8d453 --- /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.24; + +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 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..61bfd0f --- /dev/null +++ b/packages/evm/contracts/utils/BytesHelpers.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.24; + +/** + * @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) { + return readWord(data, 0); + } + + /** + * @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) { + return readWord(data, 1); + } + + /** + * @dev Reads the N-th 32-byte word of a bytes array + * @param data Bytes array to read from + * @param index Word index to read (0-based) + * @return result N-th ABI word of `data` + */ + function readWord(bytes memory data, uint256 index) private pure returns (uint256 result) { + assembly { + result := mload(add(data, add(32, mul(index, 32)))) + } + } + + /** + * @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) + mcopy(dst, src, len) + } + } + + /** + * @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/package.json b/packages/evm/package.json index 944a9df..5fa448d 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -17,7 +17,7 @@ "@openzeppelin/contracts": "5.3.0" }, "devDependencies": { - "@mimicprotocol/sdk": "0.0.1-rc.20", + "@mimicprotocol/sdk": "~0.1.0", "@nomicfoundation/hardhat-ethers": "^4.0.0-next.23", "@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.0-next.23", "@nomicfoundation/hardhat-ignition": "^3.0.0-next.23", diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 2c1fd3b..3a26fc8 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -8,6 +8,7 @@ import { OpType, randomEvmAddress, randomHex, + randomNumber, randomSig, USD_ADDRESS, ZERO_ADDRESS, @@ -20,11 +21,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 +40,9 @@ import { createCallProposal, createCrossChainSwapIntent, createCrossChainSwapOperation, + createDynamicCallIntent, + createDynamicCallOperation, + createDynamicCallProposal, createIntent, createProposal, createSwapIntent, @@ -47,9 +52,11 @@ import { createTransferOperation, createTransferProposal, currentTimestamp, + DynamicCallOperation, hashIntent, hashProposal, Intent, + literal, Proposal, signProposal, SwapOperation, @@ -58,12 +65,14 @@ import { 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,292 @@ describe('Settler', () => { }) }) + context('dynamic call', () => { + let user: SmartAccount + let target: Account + let feeToken: TokenMock + let proposal: Proposal + let dynamicCallEncoder: DynamicCallEncoder + + const arg0 = randomEvmAddress() + const arg1 = randomNumber(2) + 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'], [arg0])], + value, + }, + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [literal(['uint256'], [arg1])], + value: 0n, + }, + ], + 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(arg0) + + const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs[1]) + expect(decodedB).to.be.equal(arg1) + }) + + 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 + call + dynamic call + 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 callOperation = createCallOperation({ + user: smartAccount, + chainId, + calls: [{ target: tokenOutA, data: tokenOutA.interface.encodeFunctionData('decimals') }], + }) + + const dynamicCallOperation = createDynamicCallOperation({ + user: smartAccount, + chainId, + calls: [ + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [variable(0, 1)], // First operation, second output + }, + { + target, + selector: target.interface.getFunction('returnUint')!.selector, + arguments: [variable(1, 0)], // Second operation, first output + }, + ], + events: [{ topic: eventTopic, data: eventData }], + }) + + const dynamicCallOperation2 = createDynamicCallOperation({ + user: smartAccount, + chainId, + calls: [ + { + target, + selector: target.interface.getFunction('returnStruct')!.selector, + arguments: [variable(2, 1), literal(['address'], [tokenOutA.target])], // Third operation, second output + literal + }, + ], + events: [{ topic: eventTopic, data: eventData }], + }) + + intent = createIntent({ + settler, + feePayer: user, + maxFees: [], + operations: [swapOperation, callOperation, dynamicCallOperation, dynamicCallOperation2], + }) + }) + + 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', '0x', '0x'] + }) + + it('passes the previous outputs into the dynamic calls', async () => { + const signature = await signProposal(settler, intent, solver, proposal, admin) + const tx = await settler.execute(intent, proposal, signature) + + const events = await settler.queryFilter(settler.filters.OperationExecuted(), tx.blockNumber) + expect(events).to.have.lengthOf(2) + + // First dynamic call event + expect(events[0].args.opType).to.be.equal(OpType.EvmDynamicCall) + expect(events[0].args.topic).to.be.equal(eventTopic) + expect(events[0].args.data).to.be.equal(eventData) + + const [outputs0] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[0].args.output) + expect(outputs0).to.have.lengthOf(2) + + const [decodedA] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs0[0]) + expect(decodedA).to.be.equal(swapAmountOutB) + + const [decodedB] = AbiCoder.defaultAbiCoder().decode(['uint256'], outputs0[1]) + expect(decodedB).to.be.equal(6n) + + // Second dynamic call event + expect(events[1].args.opType).to.be.equal(OpType.EvmDynamicCall) + expect(events[1].args.topic).to.be.equal(eventTopic) + expect(events[1].args.data).to.be.equal(eventData) + + const [outputs1] = AbiCoder.defaultAbiCoder().decode(['bytes[]'], events[1].args.output) + expect(outputs1).to.have.lengthOf(1) + + const [decodedStruct] = AbiCoder.defaultAbiCoder().decode(['tuple(uint256 a,address b)'], outputs1[0]) + expect(decodedStruct.a).to.be.equal(6n) + expect(decodedStruct.b).to.be.equal(tokenOutA.target) + + // All operations calls + const callEvents = await smartAccount.queryFilter(smartAccount.filters.Called(), tx.blockNumber) + expect(callEvents).to.have.lengthOf(4) + + expect(callEvents[0].args.data).to.be.equal(tokenOutA.interface.encodeFunctionData('decimals')) + expect(callEvents[1].args.data).to.be.equal( + target.interface.encodeFunctionData('returnUint', [swapAmountOutB]) + ) + expect(callEvents[2].args.data).to.be.equal(target.interface.encodeFunctionData('returnUint', [6n])) + expect(callEvents[3].args.data).to.be.equal( + target.interface.encodeFunctionData('returnStruct', [[6n, tokenOutA.target]]) + ) + }) + }) + 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..f0c1689 --- /dev/null +++ b/packages/evm/test/dynamic-calls/DynamicCallEncoder.test.ts @@ -0,0 +1,286 @@ +import { randomEvmAddress } from '@mimicprotocol/sdk' +import { expect } from 'chai' +import { network } from 'hardhat' + +import { DynamicCallEncoder } from '../../types/ethers-contracts/index.js' +import { DynamicArg, literal, 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 number(uint256 value) view returns (uint256)', + 'function bar(uint256[2])', + 'function baz((uint256,address))', + 'function qux(uint256,uint256[2])', + 'function nested((uint256[],uint256[2],(address,uint256)[])[])', + ]) + + function dynamicCall(method: string, args: DynamicArg[]) { + 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], true)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('foo', [values])) + }) + }) + + context('when a static uint256 equals an ABI dynamic offset', () => { + const value = 96n + const call = dynamicCall('number', [literal(['uint256'], [value])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('number', [value])) + }) + }) + + context('when a static address equals an ABI dynamic offset', () => { + const value = '0x0000000000000000000000000000000000000060' + const call = dynamicCall('balanceOf', [literal(['address'], [value])]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('balanceOf', [value])) + }) + }) + + context('with an array of structs containing nested arrays', () => { + const values = [ + [ + [1n, 2n, 3n], + [4n, 5n], + [ + [randomEvmAddress(), 6n], + [randomEvmAddress(), 7n], + ], + ], + [[8n, 9n], [10n, 11n], [[randomEvmAddress(), 12n]]], + ] + const call = dynamicCall('nested', [ + literal(['tuple(uint256[],uint256[2],tuple(address,uint256)[])[]'], [values], true), + ]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('nested', [values])) + }) + }) + }) + + context('with variable arguments', () => { + context('when the variable spec is correct', () => { + const var0 = 100n + const var1 = randomEvmAddress() + const var2 = [1, 2, 3, 4, 5, 6, 7] + const var3 = [11n, 22n] + const var4 = [33n, randomEvmAddress()] + const var5 = [32n, 99n] + const var6 = [ + [ + [1n, 2n, 3n], + [4n, 5n], + [ + [randomEvmAddress(), 6n], + [randomEvmAddress(), 7n], + ], + ], + [[], [10n, 11n], [[randomEvmAddress(), 12n]]], + ] + + // variables[opIndex][subIndex] + const variables = [ + [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [var0]), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [var1]), + ], + [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[]'], [var2]), + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[2]'], [var3]), + ethers.AbiCoder.defaultAbiCoder().encode(['tuple(uint256,address)'], [var4]), + ethers.AbiCoder.defaultAbiCoder().encode(['uint256[2]'], [var5]), + ethers.AbiCoder.defaultAbiCoder().encode( + ['tuple(uint256[],uint256[2],tuple(address,uint256)[])[]'], + [var6] + ), + ], + ] + + context('with a single argument', () => { + 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, true)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('foo', [var2])) + }) + }) + + context('with multi-word static arguments', () => { + const call = dynamicCall('bar', [variable(1, 1)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('bar', [var3])) + }) + }) + + context('with static tuple arguments', () => { + const call = dynamicCall('baz', [variable(1, 2)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('baz', [var4])) + }) + }) + + context('when a static value starts with an ABI dynamic offset', () => { + const value = 1n + const call = dynamicCall('qux', [literal(['uint256'], [value]), variable(1, 3)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('qux', [value, var5])) + }) + }) + + context('with an array of structs containing nested arrays', () => { + const call = dynamicCall('nested', [variable(1, 4, true)]) + + it('encodes arguments properly', async () => { + const encoded = await encoder.encode(call, variables, variables.length) + expect(encoded).to.equal(iface.encodeFunctionData('nested', [var6])) + }) + }) + }) + + context('when the variable spec is invalid', () => { + context('when variable ref is not 64 bytes', () => { + const call = dynamicCall('foo', [{ kind: 1, data: '0x11', isDynamic: false }]) + + 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 not word-aligned', () => { + const variables = [['0x1234']] + const call = dynamicCall('transfer', [literal(['address'], [randomEvmAddress()]), variable(0, 0)]) + + it('reverts with DynamicCallEncoderBadLength', async () => { + await expect(encoder.encode(call, variables, variables.length)).to.be.revertedWithCustomError( + encoder, + 'DynamicCallEncoderBadLength' + ) + }) + }) + }) + }) + + 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..9170601 --- /dev/null +++ b/packages/evm/test/helpers/dynamic-calls.ts @@ -0,0 +1,14 @@ +import { AbiCoder } from 'ethers' + +export type DynamicArg = { kind: number; data: string; isDynamic: boolean } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function literal(types: string[], values: any[], isDynamic = false): DynamicArg { + const data = AbiCoder.defaultAbiCoder().encode(types, values) + return { kind: 0, data, isDynamic } +} + +export function variable(opIndex: number, subIndex: number, isDynamic = false): DynamicArg { + const data = AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [opIndex, subIndex]) + return { kind: 1, data, isDynamic } +} 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..30921c2 --- /dev/null +++ b/packages/evm/test/helpers/intents/dynamic-call.ts @@ -0,0 +1,68 @@ +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, bool isDynamic)[] 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..d98a9e8 --- /dev/null +++ b/packages/evm/test/smart-accounts/SmartAccountsHandlerHelpers.test.ts @@ -0,0 +1,75 @@ +import { randomEvmAddress } from '@mimicprotocol/sdk' +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', () => { + const itReturnsTheExpectedBytes = ( + name: string, + args: unknown[], + types: string[], + expectedDecoded: unknown[] + ): void => { + it('returns the expected bytes', async () => { + const data = target.interface.encodeFunctionData(name, args) + const result = await helper.call.staticCall(handler, smartAccount, target, data, 0) + const expected = target.interface.encodeFunctionResult(name, args) + + expect(result).to.equal(expected) + expect(AbiCoder.defaultAbiCoder().decode(types, result)).to.deep.equal(expectedDecoded) + }) + } + + context('when returning a uint256', () => { + itReturnsTheExpectedBytes('returnUint', [11n], ['uint256'], [11n]) + }) + + context('when returning a dynamic array', () => { + const dynamicArray = [11n, 22n, 33n] + + itReturnsTheExpectedBytes('returnArray', [dynamicArray], ['uint256[]'], [dynamicArray]) + }) + + context('when returning a fixed-length array', () => { + const fixedArray = [44n, 55n, 66n] + + itReturnsTheExpectedBytes('returnFixedArray', [fixedArray], ['uint256[3]'], [fixedArray]) + }) + + context('when returning a struct', () => { + const struct = { a: 77n, b: randomEvmAddress() } + + itReturnsTheExpectedBytes('returnStruct', [struct], ['tuple(uint256 a,address b)'], [[struct.a, struct.b]]) + }) + }) +}) diff --git a/packages/evm/test/utils/BytesHelpers.test.ts b/packages/evm/test/utils/BytesHelpers.test.ts new file mode 100644 index 0000000..6ca2db2 --- /dev/null +++ b/packages/evm/test/utils/BytesHelpers.test.ts @@ -0,0 +1,143 @@ +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('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' + ) + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 70bafcf..e57e304 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,7 +48,7 @@ resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.31.1.tgz#d635cbac2533973ae6bfb5d3ba1de89ce5aece2d" integrity sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ== -"@coral-xyz/anchor@^0.32.1": +"@coral-xyz/anchor@0.32.1", "@coral-xyz/anchor@^0.32.1": version "0.32.1" resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.32.1.tgz#a07440d9d267840f4f99f1493bd8ce7d7f128e57" integrity sha512-zAyxFtfeje2FbMA1wzgcdVs7Hng/MijPKpRijoySPCicnvcTQs/+dnPZ/cR+LcXM9v9UYSyW81uRNYZtN5G4yg== @@ -482,11 +482,12 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" -"@mimicprotocol/sdk@0.0.1-rc.20": - version "0.0.1-rc.20" - resolved "https://registry.yarnpkg.com/@mimicprotocol/sdk/-/sdk-0.0.1-rc.20.tgz#a2a29c53b17901c38c25e39304ddcf46a72058f2" - integrity sha512-Hdw9jvyVI3ox6SbY/AI22yi55gDlf1Qx33UHdN1jCR+IL350rHBKO2b+L2zvSYIH4mcJWhXAofREM4zwKuqVBw== +"@mimicprotocol/sdk@0.0.2-rc.1": + version "0.0.2-rc.1" + resolved "https://registry.yarnpkg.com/@mimicprotocol/sdk/-/sdk-0.0.2-rc.1.tgz#2d8f69a006709c15631ae60e9ec437be9ec178ab" + integrity sha512-mGuBKp4JCdJh0b/awozp1qUULYdbRmsXdmVuwbT1ZZfVnZmlSpANqkiA2PGPUB8VEdQ9NvhgWhZcFZJzHy2exQ== dependencies: + "@coral-xyz/anchor" "0.32.1" "@solana/web3.js" "^1.98.4" borsh "^2.0.0" cron-parser "^5.3.1"