diff --git a/snapshots/PriceOracleValidationTest.json b/snapshots/PriceOracleValidationTest.json new file mode 100644 index 00000000..796e231a --- /dev/null +++ b/snapshots/PriceOracleValidationTest.json @@ -0,0 +1,3 @@ +{ + "testPriceOracleValidationSucceeds": "176008" +} \ No newline at end of file diff --git a/src/interfaces/IValidationCallback.sol b/src/interfaces/IValidationCallback.sol index 0e7f6680..41817a97 100644 --- a/src/interfaces/IValidationCallback.sol +++ b/src/interfaces/IValidationCallback.sol @@ -8,5 +8,5 @@ interface IValidationCallback { /// @notice Called by the reactor for custom validation of an order. Will revert if validation fails /// @param filler The filler of the order /// @param resolvedOrder The resolved order to fill - function validate(address filler, ResolvedOrder calldata resolvedOrder) external view; + function validate(address filler, ResolvedOrder calldata resolvedOrder) external; } diff --git a/src/lib/ResolvedOrderLib.sol b/src/lib/ResolvedOrderLib.sol index a27a17cf..239e9765 100644 --- a/src/lib/ResolvedOrderLib.sol +++ b/src/lib/ResolvedOrderLib.sol @@ -10,7 +10,7 @@ library ResolvedOrderLib { /// @notice Validates a resolved order, reverting if invalid /// @param filler The filler of the order - function validate(ResolvedOrder memory resolvedOrder, address filler) internal view { + function validate(ResolvedOrder memory resolvedOrder, address filler) internal { if (address(this) != address(resolvedOrder.info.reactor)) { revert InvalidReactor(); } diff --git a/src/sample-validation-contracts/PriceOracleValidation.sol b/src/sample-validation-contracts/PriceOracleValidation.sol new file mode 100644 index 00000000..cf093a1a --- /dev/null +++ b/src/sample-validation-contracts/PriceOracleValidation.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IValidationCallback} from "../interfaces/IValidationCallback.sol"; +import {ResolvedOrder} from "../base/ReactorStructs.sol"; +import {ISwapRouter02} from "../external/ISwapRouter02.sol"; + +/// @notice Validation contract that checks +/// @dev uses swapRouter02 +contract PriceOracleValidation is IValidationCallback { + error FailedToCallValidationContract(bytes reason); + error InsufficientOutput(uint256 minOutput, uint256 actualOutput); + + function validate(address, ResolvedOrder calldata resolvedOrder) external { + (address to, bytes memory data) = abi.decode(resolvedOrder.info.additionalValidationData, (address, bytes)); + + // No strict interface enforced here + (bool success, bytes memory returnData) = address(to).call(data); + if (!success) { + revert FailedToCallValidationContract(returnData); + } + uint256 amountOut = abi.decode(returnData, (uint256)); + + uint256 totalOutputAmount; + for (uint256 i = 0; i < resolvedOrder.outputs.length; i++) { + totalOutputAmount += resolvedOrder.outputs[i].amount; + } + if (amountOut < totalOutputAmount) { + revert InsufficientOutput(amountOut, totalOutputAmount); + } + } +} diff --git a/test/integration/SwapRouter02ExecutorIntegration.t.sol b/test/integration/SwapRouter02ExecutorIntegration.t.sol index 978a3a0c..b2573c1b 100644 --- a/test/integration/SwapRouter02ExecutorIntegration.t.sol +++ b/test/integration/SwapRouter02ExecutorIntegration.t.sol @@ -13,6 +13,9 @@ import {OutputsBuilder} from "../util/OutputsBuilder.sol"; import {PermitSignature} from "../util/PermitSignature.sol"; import {ISwapRouter02, ExactInputSingleParams} from "../../src/external/ISwapRouter02.sol"; import {IPermit2} from "permit2/src/interfaces/IPermit2.sol"; +import {PriceOracleValidation} from "../../src/sample-validation-contracts/PriceOracleValidation.sol"; +import {IValidationCallback} from "../../src/interfaces/IValidationCallback.sol"; +import {MockMixedRouteQuoterV1Wrapper} from "../util/mock/MockMixedRouteQuoterV1Wrapper.sol"; // This set of tests will use a mainnet fork to test integration. contract SwapRouter02IntegrationTest is Test, PermitSignature { @@ -27,6 +30,7 @@ contract SwapRouter02IntegrationTest is Test, PermitSignature { address constant WHALE = 0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E; IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3); uint256 constant ONE = 1000000000000000000; + address constant MIXED_ROUTE_QUOTER = 0x84E44095eeBfEC7793Cd7d5b57B7e401D7f1cA2E; address swapper; address swapper2; @@ -35,6 +39,8 @@ contract SwapRouter02IntegrationTest is Test, PermitSignature { address filler; SwapRouter02Executor swapRouter02Executor; DutchOrderReactor dloReactor; + IValidationCallback priceOracleValidationContract; + MockMixedRouteQuoterV1Wrapper mockMixedRouteQuoterV1Wrapper; function setUp() public { swapperPrivateKey = 0xbabe; @@ -45,6 +51,8 @@ contract SwapRouter02IntegrationTest is Test, PermitSignature { vm.createSelectFork(vm.envString("FOUNDRY_RPC_URL"), 16586505); dloReactor = new DutchOrderReactor(PERMIT2, address(0)); swapRouter02Executor = new SwapRouter02Executor(address(this), dloReactor, address(this), SWAPROUTER02); + priceOracleValidationContract = IValidationCallback(address(new PriceOracleValidation())); + mockMixedRouteQuoterV1Wrapper = new MockMixedRouteQuoterV1Wrapper(MIXED_ROUTE_QUOTER); // Swapper max approves permit post vm.prank(swapper); @@ -410,6 +418,53 @@ contract SwapRouter02IntegrationTest is Test, PermitSignature { assertEq(address(swapRouter02Executor).balance, 319317550497372609); } + // Same setup as test above + function testSwapWethToDaiViaV2_priceOracleValidationContract() public { + uint256 inputAmount = 2 * ONE; + address[] memory path = new address[](2); + path[0] = address(WETH); + path[1] = address(DAI); + bytes memory encodedPath = abi.encodePacked(path[0], uint24(uint256(3000)), path[1]); + + bytes memory additionalValidationData = abi.encode( + address(mockMixedRouteQuoterV1Wrapper), + abi.encodeWithSelector(bytes4(keccak256("quoteExactInput(bytes,uint256)")), encodedPath, inputAmount) + ); + + uint256 outputAmount = 3000 * ONE; + + DutchOrder memory order = DutchOrder({ + info: OrderInfoBuilder.init(address(dloReactor)).withSwapper(swapper).withDeadline(block.timestamp + 100) + .withValidationContract(priceOracleValidationContract).withValidationData(additionalValidationData), + decayStartTime: block.timestamp - 100, + decayEndTime: block.timestamp + 100, + input: DutchInput(WETH, inputAmount, inputAmount), + outputs: OutputsBuilder.singleDutch(address(DAI), outputAmount, outputAmount, address(swapper)) + }); + + address[] memory tokensToApproveForSwapRouter02 = new address[](1); + tokensToApproveForSwapRouter02[0] = address(WETH); + + address[] memory tokensToApproveForReactor = new address[](1); + tokensToApproveForReactor[0] = address(DAI); + bytes[] memory multicallData = new bytes[](1); + + multicallData[0] = abi.encodeWithSelector( + ISwapRouter02.swapExactTokensForTokens.selector, + inputAmount, + outputAmount, + path, + address(swapRouter02Executor) + ); + swapRouter02Executor.execute( + SignedOrder(abi.encode(order), signOrder(swapperPrivateKey, address(PERMIT2), order)), + abi.encode(tokensToApproveForSwapRouter02, tokensToApproveForReactor, multicallData) + ); + assertEq(WETH.balanceOf(swapper), ONE); + assertEq(DAI.balanceOf(swapper), outputAmount); + assertEq(DAI.balanceOf(address(swapRouter02Executor)), 275438458971501955836); + } + // There is 10 WETH swapRouter02Executor. Test that we can convert it to ETH // and withdraw successfully. function testUnwrapWETH() public { diff --git a/test/util/mock/MockMixedRouteQuoterV1Wrapper.sol b/test/util/mock/MockMixedRouteQuoterV1Wrapper.sol new file mode 100644 index 00000000..7598d6cc --- /dev/null +++ b/test/util/mock/MockMixedRouteQuoterV1Wrapper.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +/// @notice Helper contract to call MixedRouteQuoterV1 and decode the return data +contract MockMixedRouteQuoterV1Wrapper { + address private immutable quoter; + + constructor(address _quoter) { + quoter = _quoter; + } + + fallback(bytes calldata data) external returns (bytes memory) { + // quoteExactInput(bytes memory path, uint256 amountIn) + if (msg.sig != 0xcdca1753) { + revert("Invalid function call"); + } + + (bool success, bytes memory returnData) = address(quoter).call(data); + if (!success) { + revert("Failed to call quoter"); + } + + (uint256 amountOut,,,) = abi.decode(returnData, (uint256, uint160[], uint32[], uint256)); + return abi.encode(amountOut); + } +} diff --git a/test/util/mock/MockResolvedOrderLib.sol b/test/util/mock/MockResolvedOrderLib.sol index 44ddea0c..89b445d6 100644 --- a/test/util/mock/MockResolvedOrderLib.sol +++ b/test/util/mock/MockResolvedOrderLib.sol @@ -7,7 +7,7 @@ import {ResolvedOrderLib} from "../../../src/lib/ResolvedOrderLib.sol"; contract MockResolvedOrderLib { using ResolvedOrderLib for ResolvedOrder; - function validate(ResolvedOrder memory resolvedOrder, address filler) external view { + function validate(ResolvedOrder memory resolvedOrder, address filler) external { resolvedOrder.validate(filler); } } diff --git a/test/validation-contracts/PriceOracleValidation.t.sol b/test/validation-contracts/PriceOracleValidation.t.sol new file mode 100644 index 00000000..7b3f0f60 --- /dev/null +++ b/test/validation-contracts/PriceOracleValidation.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {DeployPermit2} from "../util/DeployPermit2.sol"; +import {DutchOrderReactor, DutchOrder, DutchInput} from "../../src/reactors/DutchOrderReactor.sol"; +import {OrderInfo, SignedOrder} from "../../src/base/ReactorStructs.sol"; +import {OrderInfoBuilder} from "../util/OrderInfoBuilder.sol"; +import {MockERC20} from "../util/mock/MockERC20.sol"; +import {DutchOrder, DutchOrderLib} from "../../src/lib/DutchOrderLib.sol"; +import {OutputsBuilder} from "../util/OutputsBuilder.sol"; +import {MockFillContract} from "../util/mock/MockFillContract.sol"; +import {PermitSignature} from "../util/PermitSignature.sol"; +import {PriceOracleValidation} from "../../src/sample-validation-contracts/PriceOracleValidation.sol"; +import {ResolvedOrderLib} from "../../src/lib/ResolvedOrderLib.sol"; +import {IPermit2} from "permit2/src/interfaces/IPermit2.sol"; + +contract MockPriceOracle { + fallback(bytes calldata data) external returns (bytes memory) { + return data; + } +} + +contract PriceOracleValidationTest is Test, PermitSignature, DeployPermit2 { + using OrderInfoBuilder for OrderInfo; + using DutchOrderLib for DutchOrder; + + address constant PROTOCOL_FEE_OWNER = address(1); + + MockFillContract fillContract; + MockERC20 tokenIn; + MockERC20 tokenOut; + uint256 swapperPrivateKey; + address swapper; + DutchOrderReactor reactor; + IPermit2 permit2; + PriceOracleValidation priceOracleValidation; + MockPriceOracle mockPriceOracle; + + function setUp() public { + tokenIn = new MockERC20("Input", "IN", 18); + tokenOut = new MockERC20("Output", "OUT", 18); + swapperPrivateKey = 0x12341234; + swapper = vm.addr(swapperPrivateKey); + permit2 = IPermit2(deployPermit2()); + reactor = new DutchOrderReactor(permit2, PROTOCOL_FEE_OWNER); + fillContract = new MockFillContract(address(reactor)); + mockPriceOracle = new MockPriceOracle(); + priceOracleValidation = new PriceOracleValidation(); + } + + // Test price oracle validation contract succeeds + function testPriceOracleValidationSucceeds() public { + uint256 inputAmount = 10 ** 18; + uint256 outputAmount = 2 * inputAmount; + + tokenIn.mint(address(swapper), inputAmount); + tokenOut.mint(address(fillContract), outputAmount); + tokenIn.forceApprove(swapper, address(permit2), type(uint256).max); + + DutchOrder memory order = DutchOrder({ + info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100) + .withValidationContract(priceOracleValidation).withValidationData( + // mock price oracle returns == outputAmount + abi.encode(address(mockPriceOracle), abi.encode(outputAmount)) + ), + decayStartTime: block.timestamp, + decayEndTime: block.timestamp + 100, + input: DutchInput(tokenIn, inputAmount, inputAmount), + outputs: OutputsBuilder.singleDutch(address(tokenOut), outputAmount, outputAmount, swapper) + }); + + // Below snapshot can be compared to `DutchExecuteSingle.snap` to compare an execute with and without + // exclusive filler validation + vm.startSnapshotGas("testPriceOracleValidationSucceeds"); + fillContract.execute(SignedOrder(abi.encode(order), signOrder(swapperPrivateKey, address(permit2), order))); + vm.stopSnapshotGas(); + assertEq(tokenOut.balanceOf(swapper), outputAmount); + assertEq(tokenIn.balanceOf(address(fillContract)), inputAmount); + } + + // The filler is incorrectly address(0x123) + function testPriceOracleValidationFails() public { + uint256 inputAmount = 10 ** 18; + uint256 outputAmount = 2 * inputAmount; + + tokenIn.mint(address(swapper), inputAmount); + tokenOut.mint(address(fillContract), outputAmount); + tokenIn.forceApprove(swapper, address(permit2), type(uint256).max); + + DutchOrder memory order = DutchOrder({ + info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100) + .withValidationContract(priceOracleValidation).withValidationData( + // mock price oracle returns < outputAmount + abi.encode(address(mockPriceOracle), abi.encode(outputAmount - 1)) + ), + decayStartTime: block.timestamp, + decayEndTime: block.timestamp + 100, + input: DutchInput(tokenIn, inputAmount, inputAmount), + outputs: OutputsBuilder.singleDutch(address(tokenOut), outputAmount, outputAmount, swapper) + }); + + vm.expectRevert( + abi.encodeWithSelector(PriceOracleValidation.InsufficientOutput.selector, outputAmount - 1, outputAmount) + ); + fillContract.execute(SignedOrder(abi.encode(order), signOrder(swapperPrivateKey, address(permit2), order))); + } +}