From 92bade9cd990830664c27d467f1d449907dd7b3f Mon Sep 17 00:00:00 2001 From: Filip Date: Wed, 22 Apr 2026 23:16:46 +0200 Subject: [PATCH 1/5] feat: add signed integer constraints and OR predicate composition Community feedback on ERC-8211 identified two gaps in the constraint system: - Unsigned-only GTE/LTE constraints are incorrect for int256 values (two's complement makes negative numbers appear greater than positives in raw bytes32 comparison). - No way to express OR logic without deploying helper contracts. --- contracts/ComposableExecutionLib.sol | 70 ++- contracts/ComposableExecutionModule.sol | 10 +- contracts/types/ComposabilityDataTypes.sol | 15 +- test/ComposabilityBase.t.sol | 10 +- test/mock/DummyContract.sol | 4 + test/unit/ComposableExecution_Complex.sol | 24 +- ...ComposableExecution_ConstraintsReverts.sol | 538 +++++++++++++++++- test/unit/ComposableExecution_Simple.t.sol | 22 +- 8 files changed, 598 insertions(+), 95 deletions(-) diff --git a/contracts/ComposableExecutionLib.sol b/contracts/ComposableExecutionLib.sol index ed13823..84f21f6 100644 --- a/contracts/ComposableExecutionLib.sol +++ b/contracts/ComposableExecutionLib.sol @@ -62,11 +62,7 @@ library ComposableExecutionLib { // we don't restrict it since some calls may want to call address(0) // if a param with VALUE type was not provided, it will be 0 // this is even more often case, as many calls happen with 0 value - return Execution({ - target: composedTarget, - value: composedValue, - callData: composedCalldata - }); + return Execution({ target: composedTarget, value: composedValue, callData: composedCalldata }); } // Process a single input parameter and return the composed calldata @@ -99,8 +95,7 @@ library ComposableExecutionLib { // expect paramData to be abi.encodePacked(address token, address account) // Validate exact length requirement - require(paramData.length == 40, - InvalidParameterEncoding("Invalid paramData length")); + require(paramData.length == 40, InvalidParameterEncoding("Invalid paramData length")); assembly { tokenAddr := shr(96, calldataload(paramData.offset)) account := shr(96, calldataload(add(paramData.offset, 0x14))) @@ -170,31 +165,54 @@ library ComposableExecutionLib { } } - /// @dev Validate the constraints => compare the value with the reference data + /// @dev Validate the constraints => compare the value with the reference data. + /// Each constraints[i] is checked against the i-th 32-byte word of rawValue (AND semantics). + /// Use ConstraintType.OR to express OR semantics within a single word position. function _validateConstraints(bytes memory rawValue, Constraint[] calldata constraints) private pure { - if (constraints.length > 0) { - for (uint256 i; i < constraints.length; i++) { - Constraint memory constraint = constraints[i]; - bytes32 returnValue; - assembly { - returnValue := mload(add(rawValue, add(0x20, mul(i, 0x20)))) - } - if (constraint.constraintType == ConstraintType.EQ) { - require(returnValue == bytes32(constraint.referenceData), ConstraintNotMet(ConstraintType.EQ)); - } else if (constraint.constraintType == ConstraintType.GTE) { - require(returnValue >= bytes32(constraint.referenceData), ConstraintNotMet(ConstraintType.GTE)); - } else if (constraint.constraintType == ConstraintType.LTE) { - require(returnValue <= bytes32(constraint.referenceData), ConstraintNotMet(ConstraintType.LTE)); - } else if (constraint.constraintType == ConstraintType.IN) { - (bytes32 lowerBound, bytes32 upperBound) = abi.decode(constraint.referenceData, (bytes32, bytes32)); - require(returnValue >= lowerBound && returnValue <= upperBound, ConstraintNotMet(ConstraintType.IN)); - } else { - revert InvalidConstraintType(); + uint256 len = constraints.length; + for (uint256 i; i < len; i++) { + Constraint memory c = constraints[i]; + bytes32 value; + assembly { + value := mload(add(rawValue, add(0x20, mul(i, 0x20)))) + } + if (c.constraintType == ConstraintType.OR) { + Constraint[] memory subs = abi.decode(c.referenceData, (Constraint[])); + bool anyMet; + for (uint256 j; j < subs.length; j++) { + if (_checkConstraint(value, subs[j])) { + anyMet = true; + break; + } } + if (!anyMet) revert ConstraintNotMet(ConstraintType.OR); + } else { + if (!_checkConstraint(value, c)) revert ConstraintNotMet(c.constraintType); } } } + /// @dev Returns true if value satisfies constraint c. OR nesting is not supported. + function _checkConstraint(bytes32 value, Constraint memory c) private pure returns (bool) { + ConstraintType ct = c.constraintType; + if (ct == ConstraintType.EQ) { + return value == bytes32(c.referenceData); + } else if (ct == ConstraintType.GTE) { + return value >= bytes32(c.referenceData); + } else if (ct == ConstraintType.LTE) { + return value <= bytes32(c.referenceData); + } else if (ct == ConstraintType.IN) { + (bytes32 lower, bytes32 upper) = abi.decode(c.referenceData, (bytes32, bytes32)); + return value >= lower && value <= upper; + } else if (ct == ConstraintType.GTE_SIGNED) { + return int256(uint256(value)) >= int256(uint256(bytes32(c.referenceData))); + } else if (ct == ConstraintType.LTE_SIGNED) { + return int256(uint256(value)) <= int256(uint256(bytes32(c.referenceData))); + } else { + revert InvalidConstraintType(); + } + } + /// @dev Parse the return data and write to the appropriate storage contract function _parseReturnDataAndWriteToStorage( uint256 returnValues, diff --git a/contracts/ComposableExecutionModule.sol b/contracts/ComposableExecutionModule.sol index 63c0e9a..6122fd6 100644 --- a/contracts/ComposableExecutionModule.sol +++ b/contracts/ComposableExecutionModule.sol @@ -84,7 +84,7 @@ contract ComposableExecutionModule is IComposableExecutionModule, IExecutor, ERC function _executeComposable( ComposableExecution[] calldata cExecutions, address account, - function(Execution memory execution) internal returns(bytes[] memory) executeExecutionFunction + function(Execution memory execution) internal returns (bytes[] memory) executeExecutionFunction ) internal { @@ -107,10 +107,10 @@ contract ComposableExecutionModule is IComposableExecutionModule, IExecutor, ERC /// @dev function to be used as an argument for _executeComposable in case of regular call function _executeExecutionCall(Execution memory execution) internal returns (bytes[] memory) { - return IERC7579Account(msg.sender).executeFromExecutor({ - mode: ModeLib.encodeSimpleSingle(), - executionCalldata: ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData) - }); + return IERC7579Account(msg.sender) + .executeFromExecutor({ + mode: ModeLib.encodeSimpleSingle(), executionCalldata: ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData) + }); } /// @dev function to be used as an argument for _executeComposable in case of delegatecall diff --git a/contracts/types/ComposabilityDataTypes.sol b/contracts/types/ComposabilityDataTypes.sol index e3e964f..541ed70 100644 --- a/contracts/types/ComposabilityDataTypes.sol +++ b/contracts/types/ComposabilityDataTypes.sol @@ -6,7 +6,6 @@ enum InputParamType { TARGET, // The target address VALUE, // The value CALL_DATA // The call data - } // Parameter type for composition @@ -14,22 +13,22 @@ enum InputParamFetcherType { RAW_BYTES, // Already encoded bytes STATIC_CALL, // Perform a static call BALANCE // Get the balance of an address - } enum OutputParamFetcherType { EXEC_RESULT, // The return of the execution call STATIC_CALL // Call to some other function - } // Constraint type for parameter validation enum ConstraintType { - EQ, // Equal to - GTE, // Greater than or equal to - LTE, // Less than or equal to - IN // In range - + EQ, // Equal to (unsigned / bitwise) + GTE, // Greater than or equal to (unsigned) + LTE, // Less than or equal to (unsigned) + IN, // In range (unsigned) + GTE_SIGNED, // Greater than or equal to (signed int256) + LTE_SIGNED, // Less than or equal to (signed int256) + OR // At least one sub-constraint must pass; referenceData = abi.encode(Constraint[]) } // Constraint for parameter validation diff --git a/test/ComposabilityBase.t.sol b/test/ComposabilityBase.t.sol index ad2deac..15b32d6 100644 --- a/test/ComposabilityBase.t.sol +++ b/test/ComposabilityBase.t.sol @@ -63,19 +63,13 @@ contract ComposabilityTestBase is Test { function _createRawTargetInputParam(address target) internal returns (InputParam memory) { return InputParam({ - paramType: InputParamType.TARGET, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(target), - constraints: emptyConstraints + paramType: InputParamType.TARGET, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(target), constraints: emptyConstraints }); } function _createRawValueInputParam(uint256 value) internal returns (InputParam memory) { return InputParam({ - paramType: InputParamType.VALUE, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(value), - constraints: emptyConstraints + paramType: InputParamType.VALUE, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(value), constraints: emptyConstraints }); } } diff --git a/test/mock/DummyContract.sol b/test/mock/DummyContract.sol index 9999d28..c61b0ec 100644 --- a/test/mock/DummyContract.sol +++ b/test/mock/DummyContract.sol @@ -110,4 +110,8 @@ contract DummyContract { function payableEmit() external payable { emit Received(msg.value); } + + function getSignedValue() external pure returns (int256) { + return -42; + } } diff --git a/test/unit/ComposableExecution_Complex.sol b/test/unit/ComposableExecution_Complex.sol index 9e91419..4a1011c 100644 --- a/test/unit/ComposableExecution_Complex.sol +++ b/test/unit/ComposableExecution_Complex.sol @@ -94,7 +94,9 @@ contract ComposableExecutionTestComplexCases is ComposabilityTestBase { OutputParam[] memory outputParams = new OutputParam[](1); outputParams[0] = OutputParam({ fetcherType: OutputParamFetcherType.STATIC_CALL, - paramData: abi.encode(4, address(dummyContract), abi.encodeWithSelector(DummyContract.returnMultipleValues.selector), address(storageContract), SLOT_A) + paramData: abi.encode( + 4, address(dummyContract), abi.encodeWithSelector(DummyContract.returnMultipleValues.selector), address(storageContract), SLOT_A + ) }); ComposableExecution[] memory executions = new ComposableExecution[](1); @@ -258,18 +260,12 @@ contract ComposableExecutionTestComplexCases is ComposabilityTestBase { // tokenIn inputParams[3] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(tokenIn), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(tokenIn), constraints: emptyConstraints }); // tokenOut inputParams[4] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(tokenOut), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(tokenOut), constraints: emptyConstraints }); // amountIn @@ -290,18 +286,12 @@ contract ComposableExecutionTestComplexCases is ComposabilityTestBase { // deadline inputParams[7] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(deadline), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(deadline), constraints: emptyConstraints }); // fee inputParams[8] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(fee), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(fee), constraints: emptyConstraints }); // === end struct == diff --git a/test/unit/ComposableExecution_ConstraintsReverts.sol b/test/unit/ComposableExecution_ConstraintsReverts.sol index 2d312a1..e9ebe27 100644 --- a/test/unit/ComposableExecution_ConstraintsReverts.sol +++ b/test/unit/ComposableExecution_ConstraintsReverts.sol @@ -77,10 +77,7 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { // Prepare invalid input param - call should revert InputParam[] memory invalidInputParams = new InputParam[](3); invalidInputParams[0] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(42), - constraints: constraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(42), constraints: constraints }); invalidInputParams[1] = _createRawTargetInputParam(address(0)); invalidInputParams[2] = _createRawValueInputParam(0); @@ -88,10 +85,7 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { // Prepare valid input param - call should succeed InputParam[] memory validInputParams = new InputParam[](3); validInputParams[0] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(43), - constraints: constraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(43), constraints: constraints }); validInputParams[1] = _createRawTargetInputParam(address(0)); validInputParams[2] = _createRawValueInputParam(0); @@ -296,10 +290,7 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { // Prepare valid input param - call should succeed InputParam[] memory validInputParams = new InputParam[](3); validInputParams[0] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(42), - constraints: constraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(42), constraints: constraints }); validInputParams[1] = _createRawTargetInputParam(address(0)); validInputParams[2] = _createRawValueInputParam(0); @@ -360,8 +351,9 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { bytes memory expectedRevertReason; if (address(account) == address(mockAccountFallback)) { - expectedRevertReason = - abi.encodeWithSelector(MockAccountFallback.FallbackFailed.selector, abi.encodePacked(ComposableExecutionLib.ComposableExecutionFailed.selector)); + expectedRevertReason = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, abi.encodePacked(ComposableExecutionLib.ComposableExecutionFailed.selector) + ); } else { expectedRevertReason = abi.encodePacked(ComposableExecutionLib.ComposableExecutionFailed.selector); } @@ -439,4 +431,522 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { vm.stopPrank(); } + + // ================================================================================= + // ========================= SIGNED CONSTRAINT TESTS ============================== + // ================================================================================= + + function test_inputs_With_GteSigned_Constraints() public { + _inputParamUsingGteSignedConstraints(address(mockAccount), address(mockAccount)); + _inputParamUsingGteSignedConstraints(address(mockAccountFallback), address(composabilityHandler)); + _inputParamUsingGteSignedConstraints(address(mockAccountCaller), address(composabilityHandler)); + _inputParamUsingGteSignedConstraints(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); + } + + function test_inputs_With_LteSigned_Constraints() public { + _inputParamUsingLteSignedConstraints(address(mockAccount), address(mockAccount)); + _inputParamUsingLteSignedConstraints(address(mockAccountFallback), address(composabilityHandler)); + _inputParamUsingLteSignedConstraints(address(mockAccountCaller), address(composabilityHandler)); + _inputParamUsingLteSignedConstraints(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); + } + + function test_inputs_With_GteSigned_StaticCall_Constraints() public { + _inputParamUsingGteSignedConstraintsViaStaticCall(address(mockAccount), address(mockAccount)); + _inputParamUsingGteSignedConstraintsViaStaticCall(address(mockAccountFallback), address(composabilityHandler)); + _inputParamUsingGteSignedConstraintsViaStaticCall(address(mockAccountCaller), address(composabilityHandler)); + _inputParamUsingGteSignedConstraintsViaStaticCall(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); + } + + function test_inputs_With_Or_Constraints() public { + _inputParamUsingOrConstraints(address(mockAccount), address(mockAccount)); + _inputParamUsingOrConstraints(address(mockAccountFallback), address(composabilityHandler)); + _inputParamUsingOrConstraints(address(mockAccountCaller), address(composabilityHandler)); + _inputParamUsingOrConstraints(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); + } + + function test_inputs_With_Or_Signed_Constraints() public { + _inputParamUsingOrWithSignedConstraints(address(mockAccount), address(mockAccount)); + _inputParamUsingOrWithSignedConstraints(address(mockAccountFallback), address(composabilityHandler)); + _inputParamUsingOrWithSignedConstraints(address(mockAccountCaller), address(composabilityHandler)); + _inputParamUsingOrWithSignedConstraints(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); + } + + // ----------------------------------------------------------------------- + // GTE_SIGNED: checks that int256(-5) is the lower bound. + // value = int256(-10) => fails (below bound) + // value = int256(-5) => passes (equal to bound) + // value = int256(0) => passes (positive is always above negative bound) + // ----------------------------------------------------------------------- + function _inputParamUsingGteSignedConstraints(address account, address caller) internal { + // Reference: value must be >= int256(-5) + Constraint[] memory constraints = new Constraint[](1); + constraints[0] = Constraint({ constraintType: ConstraintType.GTE_SIGNED, referenceData: abi.encode(int256(-5)) }); + + vm.startPrank(ENTRYPOINT_V07_ADDRESS); + + OutputParam[] memory outputParams = new OutputParam[](0); + + // int256(-10) < int256(-5) => should revert + { + InputParam[] memory invalidInputParams = new InputParam[](3); + invalidInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-10)), constraints: constraints + }); + invalidInputParams[1] = _createRawTargetInputParam(address(0)); + invalidInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory failingExecutions = new ComposableExecution[](1); + failingExecutions[0] = ComposableExecution({ functionSig: "", inputParams: invalidInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, + abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.GTE_SIGNED) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.GTE_SIGNED); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(failingExecutions); + } + + // Demonstrate why GTE_SIGNED is needed: int256(-1) is encoded as 0xffff...ff, which as + // uint256 is type(uint256).max, so plain GTE incorrectly treats -1 as >= any positive value. + // GTE_SIGNED correctly identifies -1 < 0 and rejects it. + { + Constraint[] memory unsignedConstraints = new Constraint[](1); + // plain GTE: require value >= 0 (as raw bytes32) + unsignedConstraints[0] = Constraint({ constraintType: ConstraintType.GTE, referenceData: abi.encode(bytes32(uint256(0))) }); + + InputParam[] memory unsignedInputParams = new InputParam[](3); + unsignedInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, + fetcherType: InputParamFetcherType.RAW_BYTES, + paramData: abi.encode(int256(-1)), // -1 as bytes32 = 0xffff...ff > 0 (unsigned) + constraints: unsignedConstraints + }); + unsignedInputParams[1] = _createRawTargetInputParam(address(0)); + unsignedInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory unsignedExecutions = new ComposableExecution[](1); + unsignedExecutions[0] = ComposableExecution({ functionSig: "", inputParams: unsignedInputParams, outputParams: outputParams }); + + // Unsigned GTE passes (wrongly treats -1 as >= 0 because bytes32(-1) is max uint256) + IComposableExecution(address(account)).executeComposable(unsignedExecutions); + } + + // Verify GTE_SIGNED rejects -1 >= 0 correctly + { + Constraint[] memory signedZeroConstraints = new Constraint[](1); + signedZeroConstraints[0] = Constraint({ constraintType: ConstraintType.GTE_SIGNED, referenceData: abi.encode(int256(0)) }); + + InputParam[] memory signedZeroInputParams = new InputParam[](3); + signedZeroInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, + fetcherType: InputParamFetcherType.RAW_BYTES, + paramData: abi.encode(int256(-1)), + constraints: signedZeroConstraints + }); + signedZeroInputParams[1] = _createRawTargetInputParam(address(0)); + signedZeroInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory signedZeroExecutions = new ComposableExecution[](1); + signedZeroExecutions[0] = ComposableExecution({ functionSig: "", inputParams: signedZeroInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, + abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.GTE_SIGNED) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.GTE_SIGNED); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(signedZeroExecutions); + } + + // int256(-5) >= int256(-5) => passes + { + InputParam[] memory validInputParams = new InputParam[](3); + validInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-5)), constraints: constraints + }); + validInputParams[1] = _createRawTargetInputParam(address(0)); + validInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory validExecutions = new ComposableExecution[](1); + validExecutions[0] = ComposableExecution({ functionSig: "", inputParams: validInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(validExecutions); + } + + // int256(0) >= int256(-5) => passes (positive is always above negative) + { + InputParam[] memory positiveInputParams = new InputParam[](3); + positiveInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(0)), constraints: constraints + }); + positiveInputParams[1] = _createRawTargetInputParam(address(0)); + positiveInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory positiveExecutions = new ComposableExecution[](1); + positiveExecutions[0] = ComposableExecution({ functionSig: "", inputParams: positiveInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(positiveExecutions); + } + + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // LTE_SIGNED: checks that int256(-5) is the upper bound. + // value = int256(-3) => fails (above bound) + // value = int256(-5) => passes (equal to bound) + // value = int256(-10) => passes (below bound) + // value = int256(1) => fails (positive above negative bound) + // ----------------------------------------------------------------------- + function _inputParamUsingLteSignedConstraints(address account, address caller) internal { + // Reference: value must be <= int256(-5) + Constraint[] memory constraints = new Constraint[](1); + constraints[0] = Constraint({ constraintType: ConstraintType.LTE_SIGNED, referenceData: abi.encode(int256(-5)) }); + + vm.startPrank(ENTRYPOINT_V07_ADDRESS); + + OutputParam[] memory outputParams = new OutputParam[](0); + + // int256(-3) > int256(-5) => should revert + { + InputParam[] memory invalidInputParams = new InputParam[](3); + invalidInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-3)), constraints: constraints + }); + invalidInputParams[1] = _createRawTargetInputParam(address(0)); + invalidInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory failingExecutions = new ComposableExecution[](1); + failingExecutions[0] = ComposableExecution({ functionSig: "", inputParams: invalidInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, + abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.LTE_SIGNED) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.LTE_SIGNED); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(failingExecutions); + } + + // int256(1) > int256(-5) => should revert (positive is always above negative bound) + { + InputParam[] memory positiveInvalidInputParams = new InputParam[](3); + positiveInvalidInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(1)), constraints: constraints + }); + positiveInvalidInputParams[1] = _createRawTargetInputParam(address(0)); + positiveInvalidInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory positiveFailingExecutions = new ComposableExecution[](1); + positiveFailingExecutions[0] = ComposableExecution({ functionSig: "", inputParams: positiveInvalidInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, + abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.LTE_SIGNED) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.LTE_SIGNED); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(positiveFailingExecutions); + } + + // int256(-5) <= int256(-5) => passes + { + InputParam[] memory validInputParams = new InputParam[](3); + validInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-5)), constraints: constraints + }); + validInputParams[1] = _createRawTargetInputParam(address(0)); + validInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory validExecutions = new ComposableExecution[](1); + validExecutions[0] = ComposableExecution({ functionSig: "", inputParams: validInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(validExecutions); + } + + // int256(-10) <= int256(-5) => passes + { + InputParam[] memory belowInputParams = new InputParam[](3); + belowInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-10)), constraints: constraints + }); + belowInputParams[1] = _createRawTargetInputParam(address(0)); + belowInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory belowExecutions = new ComposableExecution[](1); + belowExecutions[0] = ComposableExecution({ functionSig: "", inputParams: belowInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(belowExecutions); + } + + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // GTE_SIGNED via STATIC_CALL: the return value comes from dummyContract.getSignedValue() + // which returns int256(-42). Constraint: value must be >= int256(-50) (passes) + // and value must be >= int256(-10) (fails) + // ----------------------------------------------------------------------- + function _inputParamUsingGteSignedConstraintsViaStaticCall(address account, address caller) internal { + vm.startPrank(ENTRYPOINT_V07_ADDRESS); + + OutputParam[] memory outputParams = new OutputParam[](0); + + // getSignedValue() returns -42; require >= -50 => passes + { + Constraint[] memory passingConstraints = new Constraint[](1); + passingConstraints[0] = Constraint({ constraintType: ConstraintType.GTE_SIGNED, referenceData: abi.encode(int256(-50)) }); + + InputParam[] memory validInputParams = new InputParam[](3); + validInputParams[0] = _createRawTargetInputParam(address(0)); + validInputParams[1] = _createRawValueInputParam(0); + validInputParams[2] = InputParam({ + paramType: InputParamType.CALL_DATA, + fetcherType: InputParamFetcherType.STATIC_CALL, + paramData: abi.encode(address(dummyContract), abi.encodeWithSelector(DummyContract.getSignedValue.selector)), + constraints: passingConstraints + }); + + ComposableExecution[] memory validExecutions = new ComposableExecution[](1); + validExecutions[0] = ComposableExecution({ functionSig: "", inputParams: validInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(validExecutions); + } + + // getSignedValue() returns -42; require >= -10 => fails (-42 < -10) + { + Constraint[] memory failingConstraints = new Constraint[](1); + failingConstraints[0] = Constraint({ constraintType: ConstraintType.GTE_SIGNED, referenceData: abi.encode(int256(-10)) }); + + InputParam[] memory invalidInputParams = new InputParam[](3); + invalidInputParams[0] = _createRawTargetInputParam(address(0)); + invalidInputParams[1] = _createRawValueInputParam(0); + invalidInputParams[2] = InputParam({ + paramType: InputParamType.CALL_DATA, + fetcherType: InputParamFetcherType.STATIC_CALL, + paramData: abi.encode(address(dummyContract), abi.encodeWithSelector(DummyContract.getSignedValue.selector)), + constraints: failingConstraints + }); + + ComposableExecution[] memory failingExecutions = new ComposableExecution[](1); + failingExecutions[0] = ComposableExecution({ functionSig: "", inputParams: invalidInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, + abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.GTE_SIGNED) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.GTE_SIGNED); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(failingExecutions); + } + + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // OR constraint: value must be == 0 OR >= 100 (i.e. "free or large enough") + // value = 0 => passes (EQ branch) + // value = 150 => passes (GTE branch) + // value = 50 => fails (neither branch) + // ----------------------------------------------------------------------- + function _inputParamUsingOrConstraints(address account, address caller) internal { + Constraint[] memory subConstraints = new Constraint[](2); + subConstraints[0] = Constraint({ constraintType: ConstraintType.EQ, referenceData: abi.encode(bytes32(uint256(0))) }); + subConstraints[1] = Constraint({ constraintType: ConstraintType.GTE, referenceData: abi.encode(bytes32(uint256(100))) }); + + Constraint[] memory constraints = new Constraint[](1); + constraints[0] = Constraint({ constraintType: ConstraintType.OR, referenceData: abi.encode(subConstraints) }); + + vm.startPrank(ENTRYPOINT_V07_ADDRESS); + + OutputParam[] memory outputParams = new OutputParam[](0); + + // value = 50: neither EQ(0) nor GTE(100) => should revert + { + InputParam[] memory invalidInputParams = new InputParam[](3); + invalidInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(uint256(50)), constraints: constraints + }); + invalidInputParams[1] = _createRawTargetInputParam(address(0)); + invalidInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory failingExecutions = new ComposableExecution[](1); + failingExecutions[0] = ComposableExecution({ functionSig: "", inputParams: invalidInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.OR) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.OR); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(failingExecutions); + } + + // value = 0: EQ(0) passes => OR passes + { + InputParam[] memory zeroInputParams = new InputParam[](3); + zeroInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(uint256(0)), constraints: constraints + }); + zeroInputParams[1] = _createRawTargetInputParam(address(0)); + zeroInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory zeroExecutions = new ComposableExecution[](1); + zeroExecutions[0] = ComposableExecution({ functionSig: "", inputParams: zeroInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(zeroExecutions); + } + + // value = 150: GTE(100) passes => OR passes + { + InputParam[] memory largeInputParams = new InputParam[](3); + largeInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(uint256(150)), constraints: constraints + }); + largeInputParams[1] = _createRawTargetInputParam(address(0)); + largeInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory largeExecutions = new ComposableExecution[](1); + largeExecutions[0] = ComposableExecution({ functionSig: "", inputParams: largeInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(largeExecutions); + } + + // value = 100: GTE(100) passes (boundary) => OR passes + { + InputParam[] memory boundaryInputParams = new InputParam[](3); + boundaryInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(uint256(100)), constraints: constraints + }); + boundaryInputParams[1] = _createRawTargetInputParam(address(0)); + boundaryInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory boundaryExecutions = new ComposableExecution[](1); + boundaryExecutions[0] = ComposableExecution({ functionSig: "", inputParams: boundaryInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(boundaryExecutions); + } + + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // OR with signed sub-constraints: value must be LTE_SIGNED(-100) OR GTE_SIGNED(0) + // Useful for: "price delta must be at most -100 (big drop) or at least 0 (no loss)" + // value = int256(-50) => fails (between -100 and 0, neither branch) + // value = int256(-100) => passes (LTE_SIGNED branch) + // value = int256(-200) => passes (LTE_SIGNED branch, further below) + // value = int256(0) => passes (GTE_SIGNED branch) + // value = int256(10) => passes (GTE_SIGNED branch) + // ----------------------------------------------------------------------- + function _inputParamUsingOrWithSignedConstraints(address account, address caller) internal { + Constraint[] memory subConstraints = new Constraint[](2); + subConstraints[0] = Constraint({ constraintType: ConstraintType.LTE_SIGNED, referenceData: abi.encode(int256(-100)) }); + subConstraints[1] = Constraint({ constraintType: ConstraintType.GTE_SIGNED, referenceData: abi.encode(int256(0)) }); + + Constraint[] memory constraints = new Constraint[](1); + constraints[0] = Constraint({ constraintType: ConstraintType.OR, referenceData: abi.encode(subConstraints) }); + + vm.startPrank(ENTRYPOINT_V07_ADDRESS); + + OutputParam[] memory outputParams = new OutputParam[](0); + + // value = int256(-50): -50 > -100 (LTE fails) and -50 < 0 (GTE fails) => OR fails + { + InputParam[] memory invalidInputParams = new InputParam[](3); + invalidInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-50)), constraints: constraints + }); + invalidInputParams[1] = _createRawTargetInputParam(address(0)); + invalidInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory failingExecutions = new ComposableExecution[](1); + failingExecutions[0] = ComposableExecution({ functionSig: "", inputParams: invalidInputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.OR) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.ConstraintNotMet.selector, ConstraintType.OR); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(failingExecutions); + } + + // value = int256(-100): LTE_SIGNED(-100) passes => OR passes + { + InputParam[] memory lteInputParams = new InputParam[](3); + lteInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-100)), constraints: constraints + }); + lteInputParams[1] = _createRawTargetInputParam(address(0)); + lteInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory lteExecutions = new ComposableExecution[](1); + lteExecutions[0] = ComposableExecution({ functionSig: "", inputParams: lteInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(lteExecutions); + } + + // value = int256(-200): LTE_SIGNED(-100) passes => OR passes + { + InputParam[] memory deepNegInputParams = new InputParam[](3); + deepNegInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(-200)), constraints: constraints + }); + deepNegInputParams[1] = _createRawTargetInputParam(address(0)); + deepNegInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory deepNegExecutions = new ComposableExecution[](1); + deepNegExecutions[0] = ComposableExecution({ functionSig: "", inputParams: deepNegInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(deepNegExecutions); + } + + // value = int256(0): GTE_SIGNED(0) passes => OR passes + { + InputParam[] memory zeroInputParams = new InputParam[](3); + zeroInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(0)), constraints: constraints + }); + zeroInputParams[1] = _createRawTargetInputParam(address(0)); + zeroInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory zeroExecutions = new ComposableExecution[](1); + zeroExecutions[0] = ComposableExecution({ functionSig: "", inputParams: zeroInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(zeroExecutions); + } + + // value = int256(10): GTE_SIGNED(0) passes => OR passes + { + InputParam[] memory posInputParams = new InputParam[](3); + posInputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(int256(10)), constraints: constraints + }); + posInputParams[1] = _createRawTargetInputParam(address(0)); + posInputParams[2] = _createRawValueInputParam(0); + + ComposableExecution[] memory posExecutions = new ComposableExecution[](1); + posExecutions[0] = ComposableExecution({ functionSig: "", inputParams: posInputParams, outputParams: outputParams }); + IComposableExecution(address(account)).executeComposable(posExecutions); + } + + vm.stopPrank(); + } } diff --git a/test/unit/ComposableExecution_Simple.t.sol b/test/unit/ComposableExecution_Simple.t.sol index 8784b16..867e684 100644 --- a/test/unit/ComposableExecution_Simple.t.sol +++ b/test/unit/ComposableExecution_Simple.t.sol @@ -112,7 +112,7 @@ contract ComposableExecutionTestSimpleCases is ComposabilityTestBase { functionSig: DummyContract.A.selector, inputParams: inputParamsA, // TARGET and VALUE parameters only outputParams: outputParamsA // store output of the function A() to the storage - }); + }); // Call function A IComposableExecution(address(account)).executeComposable(executions); @@ -164,10 +164,7 @@ contract ComposableExecutionTestSimpleCases is ComposabilityTestBase { InputParam[] memory inputParams = new InputParam[](3); // call data inputParams[0] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(1), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(1), constraints: emptyConstraints }); inputParams[1] = _createRawTargetInputParam(address(dummyContract)); @@ -235,16 +232,10 @@ contract ComposableExecutionTestSimpleCases is ComposabilityTestBase { inputParams_execution1[0] = _createRawTargetInputParam(address(dummyContract)); inputParams_execution1[1] = _createRawValueInputParam(valueToSend); inputParams_execution1[2] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(input1), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(input1), constraints: emptyConstraints }); inputParams_execution1[3] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(input2), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(input2), constraints: emptyConstraints }); OutputParam[] memory outputParams_execution1 = new OutputParam[](2); @@ -404,10 +395,7 @@ contract ComposableExecutionTestSimpleCases is ComposabilityTestBase { }); inputParams[1] = _createRawValueInputParam(0); inputParams[2] = InputParam({ - paramType: InputParamType.CALL_DATA, - fetcherType: InputParamFetcherType.RAW_BYTES, - paramData: abi.encode(uintToEmit), - constraints: emptyConstraints + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(uintToEmit), constraints: emptyConstraints }); OutputParam[] memory outputParams = new OutputParam[](0); From c11ff02e77535f7f2db14abe93cb79530868e370 Mon Sep 17 00:00:00 2001 From: Filip Date: Wed, 22 Apr 2026 23:25:17 +0200 Subject: [PATCH 2/5] ci: add npm ci step before forge build to install node_modules dependencies --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34a4a52..5f68426 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,9 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + - name: Install Node dependencies + run: npm ci + - name: Show Forge version run: | forge --version From 05ac89e3d03a7d64d31d04ab2baf2ef681fd3f92 Mon Sep 17 00:00:00 2001 From: Filip Date: Wed, 22 Apr 2026 23:26:43 +0200 Subject: [PATCH 3/5] ci: use npm install instead of npm ci (lock file is gitignored) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f68426..f272261 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 - name: Install Node dependencies - run: npm ci + run: npm install --ignore-scripts - name: Show Forge version run: | From 4132035def1bf7f4e600204a7455b64a88b8172e Mon Sep 17 00:00:00 2001 From: Filip Date: Thu, 23 Apr 2026 15:48:48 +0200 Subject: [PATCH 4/5] address PR feedback: unchecked loops, clearer OR encoding doc, accurate EQ/IN comments --- contracts/ComposableExecutionLib.sol | 36 ++++++++++++++++++---- contracts/types/ComposabilityDataTypes.sol | 6 ++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/contracts/ComposableExecutionLib.sol b/contracts/ComposableExecutionLib.sol index 84f21f6..2b22b7e 100644 --- a/contracts/ComposableExecutionLib.sol +++ b/contracts/ComposableExecutionLib.sol @@ -165,12 +165,28 @@ library ComposableExecutionLib { } } - /// @dev Validate the constraints => compare the value with the reference data. - /// Each constraints[i] is checked against the i-th 32-byte word of rawValue (AND semantics). - /// Use ConstraintType.OR to express OR semantics within a single word position. + /// @dev Validate the constraints => compare each 32-byte word of rawValue against constraints[i]. + /// Each constraints[i] is checked against the i-th 32-byte word of rawValue (AND semantics across + /// the array). Use ConstraintType.OR to express OR semantics within a single word position. + /// + /// AND vs OR encoding: + /// - AND is implicit across the top-level `Constraint[]`. Every non-OR entry has a static + /// 32-byte reference (EQ/GTE/LTE/GTE_SIGNED/LTE_SIGNED) or a fixed 64-byte (lower, upper) + /// payload (IN) — predictable layout, predictable gas. + /// - OR is a single entry whose `referenceData` is a dynamic `abi.encode(Constraint[])`. It is + /// decoded once here and evaluated against the *same* 32-byte word as the outer entry. The + /// sub-constraints inside the OR must be leaf constraints only — nesting OR inside OR is + /// intentionally rejected (see `_checkConstraint`) to keep what the user signs flat and + /// easy to display. Example: + /// + /// Constraint[] memory subs = new Constraint[](2); + /// subs[0] = Constraint({ constraintType: ConstraintType.EQ, referenceData: abi.encode(bytes32(uint256(0))) }); + /// subs[1] = Constraint({ constraintType: ConstraintType.GTE, referenceData: abi.encode(bytes32(uint256(100))) }); + /// Constraint memory orC = Constraint({ constraintType: ConstraintType.OR, referenceData: abi.encode(subs) }); + /// // attach orC to the InputParam; passes iff value == 0 OR value >= 100 function _validateConstraints(bytes memory rawValue, Constraint[] calldata constraints) private pure { uint256 len = constraints.length; - for (uint256 i; i < len; i++) { + for (uint256 i; i < len;) { Constraint memory c = constraints[i]; bytes32 value; assembly { @@ -178,21 +194,29 @@ library ComposableExecutionLib { } if (c.constraintType == ConstraintType.OR) { Constraint[] memory subs = abi.decode(c.referenceData, (Constraint[])); + uint256 subsLen = subs.length; bool anyMet; - for (uint256 j; j < subs.length; j++) { + for (uint256 j; j < subsLen;) { if (_checkConstraint(value, subs[j])) { anyMet = true; break; } + unchecked { + ++j; + } } if (!anyMet) revert ConstraintNotMet(ConstraintType.OR); } else { if (!_checkConstraint(value, c)) revert ConstraintNotMet(c.constraintType); } + unchecked { + ++i; + } } } - /// @dev Returns true if value satisfies constraint c. OR nesting is not supported. + /// @dev Returns true if value satisfies constraint c. OR is rejected here: nested OR is not + /// supported, so only leaf constraints may appear inside an OR's sub-array. function _checkConstraint(bytes32 value, Constraint memory c) private pure returns (bool) { ConstraintType ct = c.constraintType; if (ct == ConstraintType.EQ) { diff --git a/contracts/types/ComposabilityDataTypes.sol b/contracts/types/ComposabilityDataTypes.sol index 541ed70..5c31f9a 100644 --- a/contracts/types/ComposabilityDataTypes.sol +++ b/contracts/types/ComposabilityDataTypes.sol @@ -22,13 +22,13 @@ enum OutputParamFetcherType { // Constraint type for parameter validation enum ConstraintType { - EQ, // Equal to (unsigned / bitwise) + EQ, // Equal to (bitwise equality; suitable for signed, unsigned, addresses, bytes32) GTE, // Greater than or equal to (unsigned) LTE, // Less than or equal to (unsigned) - IN, // In range (unsigned) + IN, // In range [lower, upper] (bytes32 comparison; suitable for unsigned ranges and same-sign signed ranges) GTE_SIGNED, // Greater than or equal to (signed int256) LTE_SIGNED, // Less than or equal to (signed int256) - OR // At least one sub-constraint must pass; referenceData = abi.encode(Constraint[]) + OR // At least one sub-constraint must pass; referenceData = abi.encode(Constraint[]); sub-constraints must be leaf types (no nested OR) } // Constraint for parameter validation From d1b716229ba072fba10a3773feee0261091aa5a6 Mon Sep 17 00:00:00 2001 From: Filip Date: Thu, 23 Apr 2026 16:09:51 +0200 Subject: [PATCH 5/5] test: lock in nested-OR rejection, symmetrize OR revert --- contracts/ComposableExecutionLib.sol | 2 +- ...ComposableExecution_ConstraintsReverts.sol | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/contracts/ComposableExecutionLib.sol b/contracts/ComposableExecutionLib.sol index 2b22b7e..d60808c 100644 --- a/contracts/ComposableExecutionLib.sol +++ b/contracts/ComposableExecutionLib.sol @@ -205,7 +205,7 @@ library ComposableExecutionLib { ++j; } } - if (!anyMet) revert ConstraintNotMet(ConstraintType.OR); + if (!anyMet) revert ConstraintNotMet(c.constraintType); } else { if (!_checkConstraint(value, c)) revert ConstraintNotMet(c.constraintType); } diff --git a/test/unit/ComposableExecution_ConstraintsReverts.sol b/test/unit/ComposableExecution_ConstraintsReverts.sol index e9ebe27..3404617 100644 --- a/test/unit/ComposableExecution_ConstraintsReverts.sol +++ b/test/unit/ComposableExecution_ConstraintsReverts.sol @@ -471,6 +471,13 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { _inputParamUsingOrWithSignedConstraints(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); } + function test_Nested_Or_Reverts_With_InvalidConstraintType() public { + _nestedOrReverts(address(mockAccount), address(mockAccount)); + _nestedOrReverts(address(mockAccountFallback), address(composabilityHandler)); + _nestedOrReverts(address(mockAccountCaller), address(composabilityHandler)); + _nestedOrReverts(address(mockAccountDelegateCaller), address(mockAccountDelegateCaller)); + } + // ----------------------------------------------------------------------- // GTE_SIGNED: checks that int256(-5) is the lower bound. // value = int256(-10) => fails (below bound) @@ -949,4 +956,52 @@ contract ComposableExecutionTestConstraintsAndReverts is ComposabilityTestBase { vm.stopPrank(); } + + // ----------------------------------------------------------------------- + // Nested OR is intentionally rejected to keep the signed payload flat and + // easy to display. An OR whose sub-array contains another OR must revert + // with InvalidConstraintType when _checkConstraint encounters the inner OR. + // ----------------------------------------------------------------------- + function _nestedOrReverts(address account, address caller) internal { + // Inner OR with two leaf alternatives + Constraint[] memory innerSubs = new Constraint[](2); + innerSubs[0] = Constraint({ constraintType: ConstraintType.EQ, referenceData: abi.encode(bytes32(uint256(1))) }); + innerSubs[1] = Constraint({ constraintType: ConstraintType.EQ, referenceData: abi.encode(bytes32(uint256(2))) }); + + // Outer OR whose second alternative is the inner OR (nesting) + Constraint[] memory outerSubs = new Constraint[](2); + outerSubs[0] = Constraint({ constraintType: ConstraintType.EQ, referenceData: abi.encode(bytes32(uint256(0))) }); + outerSubs[1] = Constraint({ constraintType: ConstraintType.OR, referenceData: abi.encode(innerSubs) }); + + Constraint[] memory constraints = new Constraint[](1); + constraints[0] = Constraint({ constraintType: ConstraintType.OR, referenceData: abi.encode(outerSubs) }); + + vm.startPrank(ENTRYPOINT_V07_ADDRESS); + + // value = 7: outer OR's first alternative (EQ(0)) fails for value=7, so it reaches the + // nested inner OR, which triggers InvalidConstraintType inside _checkConstraint. + InputParam[] memory inputParams = new InputParam[](3); + inputParams[0] = InputParam({ + paramType: InputParamType.CALL_DATA, fetcherType: InputParamFetcherType.RAW_BYTES, paramData: abi.encode(uint256(7)), constraints: constraints + }); + inputParams[1] = _createRawTargetInputParam(address(0)); + inputParams[2] = _createRawValueInputParam(0); + + OutputParam[] memory outputParams = new OutputParam[](0); + ComposableExecution[] memory executions = new ComposableExecution[](1); + executions[0] = ComposableExecution({ functionSig: "", inputParams: inputParams, outputParams: outputParams }); + + bytes memory expectedRevert; + if (address(account) == address(mockAccountFallback)) { + expectedRevert = abi.encodeWithSelector( + MockAccountFallback.FallbackFailed.selector, abi.encodeWithSelector(ComposableExecutionLib.InvalidConstraintType.selector) + ); + } else { + expectedRevert = abi.encodeWithSelector(ComposableExecutionLib.InvalidConstraintType.selector); + } + vm.expectRevert(expectedRevert); + IComposableExecution(address(account)).executeComposable(executions); + + vm.stopPrank(); + } }