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