diff --git a/solidity/contracts/modules/ContractCallRequestModule.sol b/solidity/contracts/modules/ContractCallRequestModule.sol new file mode 100644 index 00000000..e7e9b5c6 --- /dev/null +++ b/solidity/contracts/modules/ContractCallRequestModule.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +import {IContractCallRequestModule} from '../../interfaces/modules/IContractCallRequestModule.sol'; +import {IAccountingExtension} from '../../interfaces/extensions/IAccountingExtension.sol'; +import {IOracle} from '../../interfaces/IOracle.sol'; +import {IModule, Module} from '../Module.sol'; + +contract ContractCallRequestModule is Module, IContractCallRequestModule { + constructor(IOracle _oracle) Module(_oracle) {} + + function decodeRequestData(bytes32 _requestId) + external + view + returns ( + address _target, + bytes4 _functionSelector, + bytes memory _data, + IAccountingExtension _accountingExtension, + IERC20 _paymentToken, + uint256 _paymentAmount + ) + { + (_target, _functionSelector, _data, _accountingExtension, _paymentToken, _paymentAmount) = + _decodeRequestData(requestData[_requestId]); + } + + function _decodeRequestData(bytes memory _encodedData) + internal + pure + returns ( + address _target, + bytes4 _functionSelector, + bytes memory _data, + IAccountingExtension _accountingExtension, + IERC20 _paymentToken, + uint256 _paymentAmount + ) + { + (_target, _functionSelector, _data, _accountingExtension, _paymentToken, _paymentAmount) = + abi.decode(_encodedData, (address, bytes4, bytes, IAccountingExtension, IERC20, uint256)); + } + + function _afterSetupRequest(bytes32 _requestId, bytes calldata _data) internal override { + (,,, IAccountingExtension _accountingExtension, IERC20 _paymentToken, uint256 _paymentAmount) = + _decodeRequestData(_data); + IOracle.Request memory _request = ORACLE.getRequest(_requestId); + _accountingExtension.bond(_request.requester, _requestId, _paymentToken, _paymentAmount); + } + + function finalizeRequest(bytes32 _requestId) external override(IModule, Module) onlyOracle { + IOracle.Request memory _request = ORACLE.getRequest(_requestId); + IOracle.Response memory _response = ORACLE.getFinalizedResponse(_requestId); + (,,, IAccountingExtension _accountingExtension, IERC20 _paymentToken, uint256 _paymentAmount) = + _decodeRequestData(requestData[_requestId]); + if (_response.createdAt != 0) { + _accountingExtension.pay(_requestId, _request.requester, _response.proposer, _paymentToken, _paymentAmount); + } else { + _accountingExtension.release(_request.requester, _requestId, _paymentToken, _paymentAmount); + } + } + + function moduleName() public pure returns (string memory _moduleName) { + _moduleName = 'ContractCallRequestModule'; + } +} diff --git a/solidity/interfaces/modules/IContractCallRequestModule.sol b/solidity/interfaces/modules/IContractCallRequestModule.sol new file mode 100644 index 00000000..52919812 --- /dev/null +++ b/solidity/interfaces/modules/IContractCallRequestModule.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +import {IRequestModule} from '../../interfaces/modules/IRequestModule.sol'; +import {IAccountingExtension} from '../../interfaces/extensions/IAccountingExtension.sol'; + +interface IContractCallRequestModule is IRequestModule { + function decodeRequestData(bytes32 _requestId) + external + view + returns ( + address _target, + bytes4 _functionSelector, + bytes memory _data, + IAccountingExtension _accountingExtension, + IERC20 _paymentToken, + uint256 _paymentAmount + ); +} diff --git a/solidity/test/unit/ContractCallRequestModule.t.sol b/solidity/test/unit/ContractCallRequestModule.t.sol new file mode 100644 index 00000000..34424ee0 --- /dev/null +++ b/solidity/test/unit/ContractCallRequestModule.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.19; + +// solhint-disable-next-line +import 'forge-std/Test.sol'; + +import { + ContractCallRequestModule, + IModule, + IOracle, + IAccountingExtension, + IERC20 +} from '../../contracts/modules/ContractCallRequestModule.sol'; + +/** + * @dev Harness to set an entry in the requestData mapping, without triggering setup request hooks + */ +contract ForTest_ContractCallRequestModule is ContractCallRequestModule { + constructor(IOracle _oracle) ContractCallRequestModule(_oracle) {} + + function forTest_setRequestData(bytes32 _requestId, bytes memory _data) public { + requestData[_requestId] = _data; + } +} + +/** + * @title HTTP Request Module Unit tests + */ +contract ContractCallRequestModule_UnitTest is Test { + // The target contract + ForTest_ContractCallRequestModule public contractCallRequestModule; + + // A mock oracle + IOracle public oracle; + + // A mock accounting extension + IAccountingExtension public accounting; + + // A mock user for testing + address _user = makeAddr('user'); + + // A second mock user for testing + address _user2 = makeAddr('user2'); + + // A mock ERC20 token + IERC20 _token = IERC20(makeAddr('ERC20')); + + // Mock data + address _targetContract = address(_token); + bytes4 _functionSelector = bytes4(abi.encodeWithSignature('allowance(address, address)')); + bytes _dataParams = abi.encode(_user, _user2); + + /** + * @notice Deploy the target and mock oracle+accounting extension + */ + function setUp() public { + oracle = IOracle(makeAddr('Oracle')); + vm.etch(address(oracle), hex'069420'); + + accounting = IAccountingExtension(makeAddr('AccountingExtension')); + vm.etch(address(accounting), hex'069420'); + + contractCallRequestModule = new ForTest_ContractCallRequestModule(oracle); + } + + /** + * @notice Test that the decodeRequestData function returns the correct values + */ + function test_decodeRequestData(bytes32 _requestId, IERC20 _paymentToken, uint256 _paymentAmount) public { + vm.assume(_requestId != bytes32(0)); + vm.assume(address(_paymentToken) != address(0)); + vm.assume(_paymentAmount > 0); + + bytes memory _requestData = + abi.encode(_targetContract, _functionSelector, _dataParams, accounting, _paymentToken, _paymentAmount); + + // Set the request data + contractCallRequestModule.forTest_setRequestData(_requestId, _requestData); + + // Decode the given request data + ( + address _decodedTarget, + bytes4 _decodedFunctionSelector, + bytes memory _decodedData, + IAccountingExtension _decodedAccountingExtension, + IERC20 _decodedPaymentToken, + uint256 _decodedPaymentAmount + ) = contractCallRequestModule.decodeRequestData(_requestId); + + // Check: decoded values match original values? + assertEq(_decodedTarget, _targetContract, 'Mismatch: decoded target'); + assertEq(_decodedFunctionSelector, _functionSelector, 'Mismatch: decoded function selector'); + assertEq(_decodedData, _dataParams, 'Mismatch: decoded data'); + assertEq(address(_decodedAccountingExtension), address(accounting), 'Mismatch: decoded accounting extension'); + assertEq(address(_decodedPaymentToken), address(_paymentToken), 'Mismatch: decoded payment token'); + assertEq(_decodedPaymentAmount, _paymentAmount, 'Mismatch: decoded payment amount'); + } + + /** + * @notice Test that the afterSetupRequest hook: + * - decodes the request data + * - gets the request from the oracle + * - calls the bond function on the accounting extension + */ + function test_afterSetupRequestTriggered( + bytes32 _requestId, + address _requester, + IERC20 _paymentToken, + uint256 _paymentAmount + ) public { + vm.assume(_requestId != bytes32(0)); + vm.assume(_requester != address(0)); + vm.assume(address(_paymentToken) != address(0)); + vm.assume(_paymentAmount > 0); + + bytes memory _requestData = + abi.encode(_targetContract, _functionSelector, _dataParams, accounting, _paymentToken, _paymentAmount); + + IOracle.Request memory _fullRequest; + _fullRequest.requester = _requester; + + // Mock and assert ext calls + vm.mockCall(address(oracle), abi.encodeCall(IOracle.getRequest, (_requestId)), abi.encode(_fullRequest)); + vm.expectCall(address(oracle), abi.encodeCall(IOracle.getRequest, (_requestId))); + + vm.mockCall( + address(accounting), + abi.encodeCall(IAccountingExtension.bond, (_requester, _requestId, _paymentToken, _paymentAmount)), + abi.encode(true) + ); + vm.expectCall( + address(accounting), + abi.encodeCall(IAccountingExtension.bond, (_requester, _requestId, _paymentToken, _paymentAmount)) + ); + + contractCallRequestModule.setupRequest(_requestId, _requestData); + + // Check: request data was set? + assertEq(contractCallRequestModule.requestData(_requestId), _requestData, 'Mismatch: Request data'); + } + + /** + * @notice Test that the moduleName function returns the correct name + */ + function test_moduleNameReturnsName() public { + assertEq(contractCallRequestModule.moduleName(), 'ContractCallRequestModule', 'Wrong module name'); + } + + /** + * @notice Test that finalizeRequest calls: + * - oracle get request + * - oracle get response + * - accounting extension pay + * - accounting extension release + */ + function test_finalizeRequestMakesCalls( + bytes32 _requestId, + address _requester, + address _proposer, + IERC20 _paymentToken, + uint256 _paymentAmount + ) public { + vm.assume(_requestId != bytes32(0)); + vm.assume(_requester != address(0)); + vm.assume(_proposer != address(0)); + vm.assume(address(_paymentToken) != address(0)); + vm.assume(_paymentAmount > 0); + + // Use the correct accounting parameters + bytes memory _requestData = + abi.encode(_targetContract, _functionSelector, _dataParams, accounting, _paymentToken, _paymentAmount); + + IOracle.Request memory _fullRequest; + _fullRequest.requester = _requester; + + IOracle.Response memory _fullResponse; + _fullResponse.proposer = _proposer; + _fullResponse.createdAt = block.timestamp; + + // Set the request data + contractCallRequestModule.forTest_setRequestData(_requestId, _requestData); + + // Mock and assert the calls + vm.mockCall(address(oracle), abi.encodeCall(IOracle.getRequest, (_requestId)), abi.encode(_fullRequest)); + vm.expectCall(address(oracle), abi.encodeCall(IOracle.getRequest, (_requestId))); + + vm.mockCall(address(oracle), abi.encodeCall(IOracle.getFinalizedResponse, (_requestId)), abi.encode(_fullResponse)); + vm.expectCall(address(oracle), abi.encodeCall(IOracle.getFinalizedResponse, (_requestId))); + + vm.mockCall( + address(accounting), + abi.encodeCall(IAccountingExtension.pay, (_requestId, _requester, _proposer, _paymentToken, _paymentAmount)), + abi.encode() + ); + vm.expectCall( + address(accounting), + abi.encodeCall(IAccountingExtension.pay, (_requestId, _requester, _proposer, _paymentToken, _paymentAmount)) + ); + + vm.startPrank(address(oracle)); + contractCallRequestModule.finalizeRequest(_requestId); + + // Test the release flow + _fullResponse.createdAt = 0; + + // Update mock call to return the response with createdAt = 0 + vm.mockCall(address(oracle), abi.encodeCall(IOracle.getFinalizedResponse, (_requestId)), abi.encode(_fullResponse)); + vm.expectCall(address(oracle), abi.encodeCall(IOracle.getFinalizedResponse, (_requestId))); + + vm.mockCall( + address(accounting), + abi.encodeCall(IAccountingExtension.release, (_requester, _requestId, _paymentToken, _paymentAmount)), + abi.encode(true) + ); + + vm.expectCall( + address(accounting), + abi.encodeCall(IAccountingExtension.release, (_requester, _requestId, _paymentToken, _paymentAmount)) + ); + + contractCallRequestModule.finalizeRequest(_requestId); + } + + /** + * @notice Test that the finalizeRequest reverts if caller is not the oracle + */ + function test_finalizeOnlyCalledByOracle(bytes32 _requestId, address _caller) public { + vm.assume(_caller != address(oracle)); + + vm.expectRevert(abi.encodeWithSelector(IModule.Module_OnlyOracle.selector)); + contractCallRequestModule.finalizeRequest(_requestId); + } +}