From 33fb9af50b2c59d3ee032eac12e73f67ea726543 Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 20 May 2026 16:17:23 +0000 Subject: [PATCH 1/2] test: salvage audit triage tests from #2514 Brings across commit 91f6e9503 from the closed PR #2514 onto post-rename main, with file paths and identifiers updated for the OrderBook -> Raindex rename: New tests covering audit-flagged paths: - spender != pool path in GenericPool _exchange (#2533) - receive() and fallback() payable in RouteProcessor arb (#2534) - onTakeOrders2 fuzz on RouteProcessor arb - LibRaindexArb finalizeArb zero-balance and fuzz (#2537) - LibRaindex doPost edge cases (#2536) - RaindexV6 takeOrder min IO is output, zero amount (#2535) - RaindexV6 multicall, negativePullPush, negativeVaultBalance(Change) Test infrastructure changes: - LibTestArb.setup and LibTestFlashBorrowerArb.setup now take separate spender and pool addresses (with a single-argument convenience overload) so split-spender exchange data can be exercised. - RaindexV6SelfTest deploys the TOFU singleton in its constructor so internal token-decimals lookups work in tests that exercise them. - New SplitSpenderExchange helper exposing a SpenderProxy and SplitSpenderPool pair. LibOrder.t.sol gets a fuzzed hash-mutation test that proves changing a single field always changes the hash. forge build passes on the resulting tree. --- ...lRaindexV6ArbOrderTaker.splitSpender.t.sol | 24 ++++++++ ...lRaindexV6FlashBorrower.splitSpender.t.sol | 26 ++++++++ ...essorRaindexV6ArbOrderTaker.fallback.t.sol | 20 +++++++ ...dexV6ArbOrderTaker.onTakeOrders2Fuzz.t.sol | 50 ++++++++++++++++ ...cessorRaindexV6ArbOrderTaker.receive.t.sol | 20 +++++++ .../raindex/RaindexV6.multicall.t.sol | 59 +++++++++++++++++++ .../raindex/RaindexV6.negativePullPush.t.sol | 42 +++++++++++++ .../RaindexV6.negativeVaultBalance.t.sol | 32 ++++++++++ ...RaindexV6.negativeVaultBalanceChange.t.sol | 40 +++++++++++++ ...aindexV6.takeOrder.minimumIOIsOutput.t.sol | 54 +++++++++++++++++ .../RaindexV6.takeOrder.zeroAmount.t.sol | 47 +++++++++++++++ test/lib/LibOrder.t.sol | 14 +++++ test/lib/LibRaindex.doPost.t.sol | 47 +++++++++++++++ test/lib/LibRaindexArb.finalizeArbFuzz.t.sol | 43 ++++++++++++++ ...LibRaindexArb.finalizeArbZeroBalance.t.sol | 23 ++++++++ test/util/abstract/RaindexV6SelfTest.sol | 11 +++- test/util/concrete/SplitSpenderExchange.sol | 35 +++++++++++ test/util/lib/LibTestArb.sol | 18 ++++-- test/util/lib/LibTestFlashBorrowerArb.sol | 22 ++++--- 19 files changed, 613 insertions(+), 14 deletions(-) create mode 100644 test/concrete/arb/GenericPoolRaindexV6ArbOrderTaker.splitSpender.t.sol create mode 100644 test/concrete/arb/GenericPoolRaindexV6FlashBorrower.splitSpender.t.sol create mode 100644 test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.fallback.t.sol create mode 100644 test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.onTakeOrders2Fuzz.t.sol create mode 100644 test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.receive.t.sol create mode 100644 test/concrete/raindex/RaindexV6.multicall.t.sol create mode 100644 test/concrete/raindex/RaindexV6.negativePullPush.t.sol create mode 100644 test/concrete/raindex/RaindexV6.negativeVaultBalance.t.sol create mode 100644 test/concrete/raindex/RaindexV6.negativeVaultBalanceChange.t.sol create mode 100644 test/concrete/raindex/RaindexV6.takeOrder.minimumIOIsOutput.t.sol create mode 100644 test/concrete/raindex/RaindexV6.takeOrder.zeroAmount.t.sol create mode 100644 test/lib/LibRaindex.doPost.t.sol create mode 100644 test/lib/LibRaindexArb.finalizeArbFuzz.t.sol create mode 100644 test/lib/LibRaindexArb.finalizeArbZeroBalance.t.sol create mode 100644 test/util/concrete/SplitSpenderExchange.sol diff --git a/test/concrete/arb/GenericPoolRaindexV6ArbOrderTaker.splitSpender.t.sol b/test/concrete/arb/GenericPoolRaindexV6ArbOrderTaker.splitSpender.t.sol new file mode 100644 index 0000000000..aa433de58e --- /dev/null +++ b/test/concrete/arb/GenericPoolRaindexV6ArbOrderTaker.splitSpender.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibTestArb, OrderTakerSetup} from "test/util/lib/LibTestArb.sol"; +import {SpenderProxy, SplitSpenderPool} from "test/util/concrete/SplitSpenderExchange.sol"; + +/// When spender != pool in the exchange data encoded in takeOrders.data, +/// the approval targets the spender while the call targets the pool. +contract GenericPoolRaindexV6ArbOrderTakerSplitSpenderTest is Test { + function testSplitSpenderExchange() external { + SpenderProxy spender = new SpenderProxy(); + SplitSpenderPool pool = new SplitSpenderPool(spender); + OrderTakerSetup memory setup = LibTestArb.setup(vm, address(spender), address(pool), 100e18); + + setup.arb.arb5(setup.raindex, setup.takeOrdersConfig, LibTestArb.noopTask()); + + // Spender approval was revoked after the exchange. + assertEq(setup.outputToken.allowance(address(setup.arb), address(spender)), 0, "spender allowance revoked"); + // Pool never had approval. + assertEq(setup.outputToken.allowance(address(setup.arb), address(pool)), 0, "pool never had allowance"); + } +} diff --git a/test/concrete/arb/GenericPoolRaindexV6FlashBorrower.splitSpender.t.sol b/test/concrete/arb/GenericPoolRaindexV6FlashBorrower.splitSpender.t.sol new file mode 100644 index 0000000000..8bda6d7167 --- /dev/null +++ b/test/concrete/arb/GenericPoolRaindexV6FlashBorrower.splitSpender.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibTestFlashBorrowerArb, FlashBorrowerSetup} from "test/util/lib/LibTestFlashBorrowerArb.sol"; +import {LibTestArb} from "test/util/lib/LibTestArb.sol"; +import {SpenderProxy, SplitSpenderPool} from "test/util/concrete/SplitSpenderExchange.sol"; + +/// When spender != pool in exchangeData, the approval targets the spender +/// while the call targets the pool. Verifies the split-address pattern works +/// end-to-end. +contract GenericPoolRaindexV6FlashBorrowerSplitSpenderTest is Test { + function testSplitSpenderExchange() external { + SpenderProxy spender = new SpenderProxy(); + SplitSpenderPool pool = new SplitSpenderPool(spender); + FlashBorrowerSetup memory setup = LibTestFlashBorrowerArb.setup(vm, address(spender), address(pool), 100e18); + + setup.arb.arb4(setup.raindex, setup.takeOrdersConfig, setup.exchangeData, LibTestArb.noopTask()); + + // Spender approval was revoked after the exchange. + assertEq(setup.outputToken.allowance(address(setup.arb), address(spender)), 0, "spender allowance revoked"); + // Pool never had approval. + assertEq(setup.outputToken.allowance(address(setup.arb), address(pool)), 0, "pool never had allowance"); + } +} diff --git a/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.fallback.t.sol b/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.fallback.t.sol new file mode 100644 index 0000000000..8f094a2e8c --- /dev/null +++ b/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.fallback.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import { + RouteProcessorRaindexV6ArbOrderTaker +} from "../../../src/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.sol"; + +/// Direct test that fallback() accepts ETH transfers with non-empty calldata. +contract RouteProcessorRaindexV6ArbOrderTakerFallbackTest is Test { + function testFallbackAcceptsEthWithData() external { + RouteProcessorRaindexV6ArbOrderTaker arb = new RouteProcessorRaindexV6ArbOrderTaker(); + vm.deal(address(this), 1 ether); + + (bool success,) = address(arb).call{value: 1 ether}(hex"deadbeef"); + assertTrue(success, "fallback() should accept ETH with data"); + assertEq(address(arb).balance, 1 ether, "arb balance after fallback"); + } +} diff --git a/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.onTakeOrders2Fuzz.t.sol b/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.onTakeOrders2Fuzz.t.sol new file mode 100644 index 0000000000..a7c9a54c86 --- /dev/null +++ b/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.onTakeOrders2Fuzz.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import { + RouteProcessorRaindexV6ArbOrderTaker +} from "../../../src/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.sol"; +import {Float} from "rain.raindex.interface/interface/IRaindexV6.sol"; +import {LibRainDeploy} from "rain.deploy/lib/LibRainDeploy.sol"; +import {LibTOFUTokenDecimals} from "rain.tofu.erc20-decimals/lib/LibTOFUTokenDecimals.sol"; +import {LibRaindexDeploy} from "../../../src/lib/deploy/LibRaindexDeploy.sol"; +import {MockToken} from "test/util/concrete/MockToken.sol"; +import {MockRouteProcessor} from "test/util/concrete/MockRouteProcessor.sol"; + +/// Fuzz test over onTakeOrders2 Float parameters to exercise +/// toFixedDecimalLossy edge cases. +contract RouteProcessorRaindexV6ArbOrderTakerOnTakeOrders2FuzzTest is Test { + RouteProcessorRaindexV6ArbOrderTaker internal arb; + MockToken internal tokenA; + MockToken internal tokenB; + + function setUp() external { + LibRainDeploy.etchZoltuFactory(vm); + LibRainDeploy.deployZoltu(LibTOFUTokenDecimals.TOFU_DECIMALS_EXPECTED_CREATION_CODE); + + tokenA = new MockToken("A", "A", 18); + tokenB = new MockToken("B", "B", 18); + + MockRouteProcessor mockRp = new MockRouteProcessor(); + vm.etch(LibRaindexDeploy.ROUTE_PROCESSOR_DEPLOYED_ADDRESS, address(mockRp).code); + + arb = new RouteProcessorRaindexV6ArbOrderTaker(); + } + + /// @dev onTakeOrders2 with fuzzed Float values must not leave tokens + /// stranded. It either succeeds (no tokens move because arb has none) + /// or reverts cleanly. + function testOnTakeOrders2FuzzedFloats(Float inputAmountSent, Float totalOutputAmount) external { + bytes memory route = abi.encode(hex""); + + // The call may revert for invalid Float values (e.g., negative + // fixed-point conversion). We just verify it doesn't panic and + // leaves no tokens behind on success. + try arb.onTakeOrders2(address(tokenA), address(tokenB), inputAmountSent, totalOutputAmount, route) { + assertEq(tokenA.balanceOf(address(arb)), 0, "tokenA balance after"); + assertEq(tokenB.balanceOf(address(arb)), 0, "tokenB balance after"); + } catch {} + } +} diff --git a/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.receive.t.sol b/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.receive.t.sol new file mode 100644 index 0000000000..b4562abd8d --- /dev/null +++ b/test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.receive.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import { + RouteProcessorRaindexV6ArbOrderTaker +} from "../../../src/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.sol"; + +/// Direct test that receive() accepts ETH transfers. +contract RouteProcessorRaindexV6ArbOrderTakerReceiveTest is Test { + function testReceiveAcceptsEth() external { + RouteProcessorRaindexV6ArbOrderTaker arb = new RouteProcessorRaindexV6ArbOrderTaker(); + vm.deal(address(this), 1 ether); + + (bool success,) = address(arb).call{value: 1 ether}(""); + assertTrue(success, "receive() should accept ETH"); + assertEq(address(arb).balance, 1 ether, "arb balance after receive"); + } +} diff --git a/test/concrete/raindex/RaindexV6.multicall.t.sol b/test/concrete/raindex/RaindexV6.multicall.t.sol new file mode 100644 index 0000000000..9b36a3df0c --- /dev/null +++ b/test/concrete/raindex/RaindexV6.multicall.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {Multicall} from "openzeppelin-contracts/contracts/utils/Multicall.sol"; +import {RaindexV6ExternalRealTest} from "test/util/abstract/RaindexV6ExternalRealTest.sol"; +import {IRaindexV6, TaskV2} from "rain.raindex.interface/interface/IRaindexV6.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; + +/// Test that the Multicall inherited from OpenZeppelin works correctly on the +/// OrderBook, allowing multiple deposit calls in a single transaction. +contract RaindexV6MulticallTest is RaindexV6ExternalRealTest { + function testMulticallDeposits() external { + address alice = address(uint160(uint256(keccak256("alice.rain.test")))); + + // Mock transferFrom for both tokens. + vm.mockCall( + address(iToken0), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, address(iRaindex)), + abi.encode(true) + ); + vm.mockCall( + address(iToken1), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, address(iRaindex)), + abi.encode(true) + ); + + // Encode two deposit4 calls. + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector( + IRaindexV6.deposit4.selector, + address(iToken0), + bytes32(uint256(0x01)), + LibDecimalFloat.packLossless(10, 0), + new TaskV2[](0) + ); + calls[1] = abi.encodeWithSelector( + IRaindexV6.deposit4.selector, + address(iToken1), + bytes32(uint256(0x02)), + LibDecimalFloat.packLossless(20, 0), + new TaskV2[](0) + ); + + vm.prank(alice); + Multicall(address(iRaindex)).multicall(calls); + + // Verify both vault balances were set. + assertEq( + Float.unwrap(iRaindex.vaultBalance2(alice, address(iToken0), bytes32(uint256(0x01)))), + Float.unwrap(LibDecimalFloat.packLossless(10, 0)) + ); + assertEq( + Float.unwrap(iRaindex.vaultBalance2(alice, address(iToken1), bytes32(uint256(0x02)))), + Float.unwrap(LibDecimalFloat.packLossless(20, 0)) + ); + } +} diff --git a/test/concrete/raindex/RaindexV6.negativePullPush.t.sol b/test/concrete/raindex/RaindexV6.negativePullPush.t.sol new file mode 100644 index 0000000000..d72360b17a --- /dev/null +++ b/test/concrete/raindex/RaindexV6.negativePullPush.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RaindexV6SelfTest} from "test/util/abstract/RaindexV6SelfTest.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {NegativePull, NegativePush} from "../../../src/concrete/raindex/RaindexV6.sol"; +import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {REVERTING_MOCK_BYTECODE} from "test/util/lib/LibTestConstants.sol"; + +/// Direct tests that `pullTokens` and `pushTokens` revert with `NegativePull` +/// and `NegativePush` when given a negative Float amount. +contract RaindexV6NegativePullPushTest is RaindexV6SelfTest { + address internal token; + + /// External wrappers so vm.expectRevert can catch the internal revert. + function externalPullTokens(address account, address token_, Float amount) external { + pullTokens(account, token_, amount); + } + + function externalPushTokens(address account, address token_, Float amount) external { + pushTokens(account, token_, amount); + } + + function setUp() external { + token = address(uint160(uint256(keccak256("token.rain.test")))); + vm.etch(token, REVERTING_MOCK_BYTECODE); + vm.mockCall(token, abi.encodeWithSelector(IERC20Metadata.decimals.selector), abi.encode(18)); + } + + function testPullTokensNegativeAmountReverts() external { + Float negativeAmount = LibDecimalFloat.packLossless(-1, 0); + vm.expectRevert(abi.encodeWithSelector(NegativePull.selector)); + this.externalPullTokens(address(0xBEEF), token, negativeAmount); + } + + function testPushTokensNegativeAmountReverts() external { + Float negativeAmount = LibDecimalFloat.packLossless(-1, 0); + vm.expectRevert(abi.encodeWithSelector(NegativePush.selector)); + this.externalPushTokens(address(0xBEEF), token, negativeAmount); + } +} diff --git a/test/concrete/raindex/RaindexV6.negativeVaultBalance.t.sol b/test/concrete/raindex/RaindexV6.negativeVaultBalance.t.sol new file mode 100644 index 0000000000..ab8a4269fe --- /dev/null +++ b/test/concrete/raindex/RaindexV6.negativeVaultBalance.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RaindexV6SelfTest} from "test/util/abstract/RaindexV6SelfTest.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {NegativeVaultBalance} from "../../../src/concrete/raindex/RaindexV6.sol"; + +/// Direct test that `decreaseVaultBalance` reverts with `NegativeVaultBalance` +/// when the decrease exceeds the current balance. +contract RaindexV6NegativeVaultBalanceTest is RaindexV6SelfTest { + /// External wrapper so vm.expectRevert can catch the internal revert. + function externalDecreaseVaultBalance(address owner, address token, bytes32 vaultId, Float amount) external { + decreaseVaultBalance(owner, token, vaultId, amount); + } + + function testDecreaseVaultBalanceBelowZeroReverts() external { + address owner = address(0xBEEF); + address token = address(0xAAAA); + bytes32 vaultId = bytes32(uint256(1)); + + // Set vault balance to 1. + increaseVaultBalance(owner, token, vaultId, LibDecimalFloat.packLossless(1, 0)); + + // Attempt to decrease by 2 — should revert with negative result (1 - 2 = -1). + Float balance = LibDecimalFloat.packLossless(1, 0); + Float decreaseAmount = LibDecimalFloat.packLossless(2, 0); + Float expectedNewBalance = LibDecimalFloat.sub(balance, decreaseAmount); + vm.expectRevert(abi.encodeWithSelector(NegativeVaultBalance.selector, expectedNewBalance)); + this.externalDecreaseVaultBalance(owner, token, vaultId, decreaseAmount); + } +} diff --git a/test/concrete/raindex/RaindexV6.negativeVaultBalanceChange.t.sol b/test/concrete/raindex/RaindexV6.negativeVaultBalanceChange.t.sol new file mode 100644 index 0000000000..898cc45674 --- /dev/null +++ b/test/concrete/raindex/RaindexV6.negativeVaultBalanceChange.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RaindexV6SelfTest} from "test/util/abstract/RaindexV6SelfTest.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {NegativeVaultBalanceChange} from "../../../src/concrete/raindex/RaindexV6.sol"; + +/// Direct tests that `increaseVaultBalance` and `decreaseVaultBalance` revert +/// with `NegativeVaultBalanceChange` when given a negative amount. +contract RaindexV6NegativeVaultBalanceChangeTest is RaindexV6SelfTest { + /// External wrappers so vm.expectRevert can catch the internal revert. + function externalIncreaseVaultBalance(address owner, address token, bytes32 vaultId, Float amount) external { + increaseVaultBalance(owner, token, vaultId, amount); + } + + function externalDecreaseVaultBalance(address owner, address token, bytes32 vaultId, Float amount) external { + decreaseVaultBalance(owner, token, vaultId, amount); + } + + function testIncreaseVaultBalanceNegativeAmountReverts() external { + address owner = address(0xBEEF); + address token = address(0xAAAA); + bytes32 vaultId = bytes32(uint256(1)); + + Float negativeAmount = LibDecimalFloat.packLossless(-1, 0); + vm.expectRevert(abi.encodeWithSelector(NegativeVaultBalanceChange.selector, negativeAmount)); + this.externalIncreaseVaultBalance(owner, token, vaultId, negativeAmount); + } + + function testDecreaseVaultBalanceNegativeAmountReverts() external { + address owner = address(0xBEEF); + address token = address(0xAAAA); + bytes32 vaultId = bytes32(uint256(1)); + + Float negativeAmount = LibDecimalFloat.packLossless(-1, 0); + vm.expectRevert(abi.encodeWithSelector(NegativeVaultBalanceChange.selector, negativeAmount)); + this.externalDecreaseVaultBalance(owner, token, vaultId, negativeAmount); + } +} diff --git a/test/concrete/raindex/RaindexV6.takeOrder.minimumIOIsOutput.t.sol b/test/concrete/raindex/RaindexV6.takeOrder.minimumIOIsOutput.t.sol new file mode 100644 index 0000000000..e0fb9ba5ad --- /dev/null +++ b/test/concrete/raindex/RaindexV6.takeOrder.minimumIOIsOutput.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {RaindexV6ExternalRealTest} from "test/util/abstract/RaindexV6ExternalRealTest.sol"; +import {LibTestTakeOrder} from "test/util/lib/LibTestTakeOrder.sol"; +import {OrderV4, TakeOrdersConfigV5, TaskV2} from "rain.raindex.interface/interface/IRaindexV6.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {MinimumIO} from "../../../src/concrete/raindex/RaindexV6.sol"; + +/// When `IOIsInput = false`, `minimumIO` is checked against `totalTakerOutput`. +/// Verify the revert fires correctly in this branch. +contract RaindexV6TakeOrderMinimumIOIsOutputTest is RaindexV6ExternalRealTest { + function testTakeOrderMinimumIOIsOutputRevert() external { + address alice = address(uint160(uint256(keccak256("alice.rain.test")))); + address bob = address(uint160(uint256(keccak256("bob.rain.test")))); + + // Deposit to alice's output vault. + vm.mockCall( + address(iToken1), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, address(iRaindex)), + abi.encode(true) + ); + vm.prank(alice); + iRaindex.deposit4( + address(iToken1), bytes32(uint256(0x01)), LibDecimalFloat.packLossless(1, 0), new TaskV2[](0) + ); + + // Order outputs 1e-18 at ratio 1. + OrderV4 memory order = LibTestTakeOrder.addOrderWithExpression( + vm, + alice, + "_ _:1e-18 1;:;", + address(iToken0), + bytes32(uint256(0x01)), + address(iToken1), + bytes32(uint256(0x01)) + ); + + // IOIsInput = false means minimumIO is checked against totalTakerOutput. + TakeOrdersConfigV5 memory takeConfig = LibTestTakeOrder.defaultTakeConfig(LibTestTakeOrder.wrapSingle(order)); + takeConfig.IOIsInput = false; + takeConfig.minimumIO = LibDecimalFloat.packLossless(1, 0); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector( + MinimumIO.selector, LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(1, -18) + ) + ); + iRaindex.takeOrders4(takeConfig); + } +} diff --git a/test/concrete/raindex/RaindexV6.takeOrder.zeroAmount.t.sol b/test/concrete/raindex/RaindexV6.takeOrder.zeroAmount.t.sol new file mode 100644 index 0000000000..02f1957519 --- /dev/null +++ b/test/concrete/raindex/RaindexV6.takeOrder.zeroAmount.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {RaindexV6ExternalRealTest} from "test/util/abstract/RaindexV6ExternalRealTest.sol"; +import {LibTestTakeOrder} from "test/util/lib/LibTestTakeOrder.sol"; +import {OrderV4, TakeOrdersConfigV5, TaskV2, IRaindexV6} from "rain.raindex.interface/interface/IRaindexV6.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {LibOrder} from "../../../src/lib/LibOrder.sol"; + +/// When an order evaluates to outputMax=0, the order is skipped and +/// OrderZeroAmount is emitted. +contract RaindexV6TakeOrderZeroAmountTest is RaindexV6ExternalRealTest { + using LibDecimalFloat for Float; + + function testTakeOrderZeroAmount() external { + address alice = address(uint160(uint256(keccak256("alice.rain.test")))); + address bob = address(uint160(uint256(keccak256("bob.rain.test")))); + + // Deposit so the order has vault balance. + vm.mockCall( + address(iToken1), + abi.encodeWithSelector(IERC20.transferFrom.selector, alice, address(iRaindex)), + abi.encode(true) + ); + vm.prank(alice); + iRaindex.deposit4( + address(iToken1), bytes32(uint256(0x01)), LibDecimalFloat.packLossless(10, 0), new TaskV2[](0) + ); + + // Order with outputMax=0 and IORatio=1 — zero output means skip. + OrderV4 memory order = LibTestTakeOrder.addOrderWithExpression( + vm, alice, "_ _:0 1;:;", address(iToken0), bytes32(uint256(0x01)), address(iToken1), bytes32(uint256(0x01)) + ); + + TakeOrdersConfigV5 memory takeConfig = LibTestTakeOrder.defaultTakeConfig(LibTestTakeOrder.wrapSingle(order)); + + vm.prank(bob); + vm.expectEmit(true, true, true, true); + emit IRaindexV6.OrderZeroAmount(bob, alice, LibOrder.hash(order)); + (Float totalTakerInput, Float totalTakerOutput) = iRaindex.takeOrders4(takeConfig); + + assertTrue(totalTakerInput.isZero(), "totalTakerInput must be zero"); + assertTrue(totalTakerOutput.isZero(), "totalTakerOutput must be zero"); + } +} diff --git a/test/lib/LibOrder.t.sol b/test/lib/LibOrder.t.sol index 52cd33c199..a7fbfba446 100644 --- a/test/lib/LibOrder.t.sol +++ b/test/lib/LibOrder.t.sol @@ -21,4 +21,18 @@ contract LibOrderTest is Test { vm.assume(keccak256(abi.encode(a)) != keccak256(abi.encode(b))); assertTrue(LibOrder.hash(a) != LibOrder.hash(b)); } + + /// Mutating a single field should always produce a different hash. + /// forge-config: default.fuzz.runs = 100 + function testHashNotEqualMutatedOwner(OrderV4 memory a, address otherOwner) public pure { + vm.assume(a.owner != otherOwner); + OrderV4 memory b = OrderV4({ + owner: otherOwner, + evaluable: a.evaluable, + validInputs: a.validInputs, + validOutputs: a.validOutputs, + nonce: a.nonce + }); + assertTrue(LibOrder.hash(a) != LibOrder.hash(b)); + } } diff --git a/test/lib/LibRaindex.doPost.t.sol b/test/lib/LibRaindex.doPost.t.sol new file mode 100644 index 0000000000..2dc419e1d0 --- /dev/null +++ b/test/lib/LibRaindex.doPost.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RaindexV6ExternalRealTest} from "test/util/abstract/RaindexV6ExternalRealTest.sol"; +import { + TaskV2, + EvaluableV4, + SignedContextV1, + IInterpreterStoreV3 +} from "rain.raindex.interface/interface/IRaindexV6.sol"; + +/// Tests for `LibRaindex.doPost` behavior via the `entask2` entry point. +contract LibRaindexDoPostTest is RaindexV6ExternalRealTest { + /// Empty post array is a no-op. + function testDoPostEmptyArray() external { + TaskV2[] memory emptyTasks = new TaskV2[](0); + iRaindex.entask2(emptyTasks); + } + + /// Task with empty bytecode is silently skipped. + function testDoPostEmptyBytecodeSkipped() external { + TaskV2[] memory tasks = new TaskV2[](1); + tasks[0] = TaskV2({ + evaluable: EvaluableV4({interpreter: iInterpreter, store: iStore, bytecode: ""}), + signedContext: new SignedContextV1[](0) + }); + iRaindex.entask2(tasks); + } + + /// store.set is not called when eval produces no writes. + function testDoPostNoStoreSetWhenNoWrites() external { + bytes memory bytecode = iParserV2.parse2("_:1;"); + + TaskV2[] memory tasks = new TaskV2[](1); + tasks[0] = TaskV2({ + evaluable: EvaluableV4({interpreter: iInterpreter, store: iStore, bytecode: bytecode}), + signedContext: new SignedContextV1[](0) + }); + + vm.record(); + iRaindex.entask2(tasks); + (, bytes32[] memory writes) = vm.accesses(address(iStore)); + + assertEq(writes.length, 0, "store.set should not be called when eval has no writes"); + } +} diff --git a/test/lib/LibRaindexArb.finalizeArbFuzz.t.sol b/test/lib/LibRaindexArb.finalizeArbFuzz.t.sol new file mode 100644 index 0000000000..616dccbdb3 --- /dev/null +++ b/test/lib/LibRaindexArb.finalizeArbFuzz.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibTestArb, ArbResult} from "test/util/lib/LibTestArb.sol"; + +contract LibRaindexArbFinalizeArbFuzzTest is Test { + /// Fuzz finalizeArb with varying amounts. After arb, all tokens must be + /// accounted for: arb contract empty, sender gets profit, OB and exchange + /// hold the rest. + function testFinalizeArbFuzz(uint256 raindexOutputAmount, uint256 swapAmount, uint256 raindexPullAmount) external { + // Bound to avoid zero-amount edge cases and overflow. + raindexOutputAmount = bound(raindexOutputAmount, 1, 1e30); + swapAmount = bound(swapAmount, 1, raindexOutputAmount); + raindexPullAmount = bound(raindexPullAmount, 1, swapAmount); + + // Exchange always has enough to fulfill the swap. + uint256 exchangeInputAmount = swapAmount; + + ArbResult memory result = LibTestArb.setupAndArb( + vm, raindexPullAmount, raindexOutputAmount, exchangeInputAmount, swapAmount, LibTestArb.noopTask(), 0 + ); + + // Arb contract must be empty. + assertEq(result.inputToken.balanceOf(address(result.arb)), 0, "arb inputToken"); + assertEq(result.outputToken.balanceOf(address(result.arb)), 0, "arb outputToken"); + + // Input token profit = swapAmount - raindexPullAmount. + uint256 expectedInputProfit = swapAmount - raindexPullAmount; + assertEq(result.inputToken.balanceOf(address(this)), expectedInputProfit, "sender inputToken profit"); + + // Output token profit = raindexOutputAmount - swapAmount. + uint256 expectedOutputProfit = raindexOutputAmount - swapAmount; + assertEq(result.outputToken.balanceOf(address(this)), expectedOutputProfit, "sender outputToken profit"); + + // OB got what it pulled. + assertEq(result.inputToken.balanceOf(address(result.raindex)), raindexPullAmount, "OB inputToken"); + + // Exchange got what was swapped in. + assertEq(result.outputToken.balanceOf(address(result.exchange)), swapAmount, "exchange outputToken"); + } +} diff --git a/test/lib/LibRaindexArb.finalizeArbZeroBalance.t.sol b/test/lib/LibRaindexArb.finalizeArbZeroBalance.t.sol new file mode 100644 index 0000000000..71fa77b162 --- /dev/null +++ b/test/lib/LibRaindexArb.finalizeArbZeroBalance.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibTestArb, ArbResult} from "test/util/lib/LibTestArb.sol"; + +contract LibRaindexArbFinalizeArbZeroBalanceTest is Test { + /// When OB consumes all tokens, both arb balances are zero and no + /// safeTransfer is called for either token. + function testFinalizeArbZeroBalanceBothTokens() external { + // OB pulls 100e18, outputs 100e18. Exchange swaps 100e18 → 100e18. + // Zero profit: arb has nothing left. + ArbResult memory result = LibTestArb.setupAndArb(vm, 100e18, 100e18, 100e18, 100e18, LibTestArb.noopTask(), 0); + + // No profit swept to sender. + assertEq(result.inputToken.balanceOf(address(this)), 0, "sender inputToken"); + assertEq(result.outputToken.balanceOf(address(this)), 0, "sender outputToken"); + // Arb contract is empty. + assertEq(result.inputToken.balanceOf(address(result.arb)), 0, "arb inputToken"); + assertEq(result.outputToken.balanceOf(address(result.arb)), 0, "arb outputToken"); + } +} diff --git a/test/util/abstract/RaindexV6SelfTest.sol b/test/util/abstract/RaindexV6SelfTest.sol index 12d4f096b4..84e5569a70 100644 --- a/test/util/abstract/RaindexV6SelfTest.sol +++ b/test/util/abstract/RaindexV6SelfTest.sol @@ -5,11 +5,18 @@ pragma solidity =0.8.25; import {Test} from "forge-std/Test.sol"; import {REVERTING_MOCK_BYTECODE} from "test/util/lib/LibTestConstants.sol"; +import {LibTOFUTokenDecimals} from "rain.tofu.erc20-decimals/lib/LibTOFUTokenDecimals.sol"; +import {LibRainDeploy} from "rain.deploy/lib/LibRainDeploy.sol"; import {RaindexV6} from "../../../src/concrete/raindex/RaindexV6.sol"; /// @title RaindexV6SelfTest /// Abstract contract that is an `RaindexV6` and can be used to test itself. /// Inherits from Test so that it can be used as a base contract for other tests. -/// Mocks all externalities during construction. -abstract contract RaindexV6SelfTest is Test, RaindexV6 {} +/// Deploys TOFU singleton so internal functions that touch token decimals work. +abstract contract RaindexV6SelfTest is Test, RaindexV6 { + constructor() { + LibRainDeploy.etchZoltuFactory(vm); + LibRainDeploy.deployZoltu(LibTOFUTokenDecimals.TOFU_DECIMALS_EXPECTED_CREATION_CODE); + } +} diff --git a/test/util/concrete/SplitSpenderExchange.sol b/test/util/concrete/SplitSpenderExchange.sol new file mode 100644 index 0000000000..67897277c9 --- /dev/null +++ b/test/util/concrete/SplitSpenderExchange.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @dev Approval target that can pull tokens on behalf of whoever approved it. +contract SpenderProxy { + using SafeERC20 for IERC20; + + function pullFrom(IERC20 token, address from, address to, uint256 amount) external { + token.safeTransferFrom(from, to, amount); + } +} + +/// @dev Pool that executes swaps by pulling tokenIn via a separate SpenderProxy +/// and sending tokenOut back to the caller. Mimics DEXes where the approval +/// target differs from the contract you call (e.g. Permit2, TokenTransferProxy). +contract SplitSpenderPool { + using SafeERC20 for IERC20; + + SpenderProxy public immutable iSpender; + + constructor(SpenderProxy spender) { + iSpender = spender; + } + + function swap(IERC20 tokenIn, IERC20 tokenOut, uint256 amount) external payable { + iSpender.pullFrom(tokenIn, msg.sender, address(this), amount); + tokenOut.safeTransfer(msg.sender, amount); + } + + receive() external payable {} +} diff --git a/test/util/lib/LibTestArb.sol b/test/util/lib/LibTestArb.sol index 769abfa41d..ab7ab3ae88 100644 --- a/test/util/lib/LibTestArb.sol +++ b/test/util/lib/LibTestArb.sol @@ -137,14 +137,22 @@ library LibTestArb { } /// Set up an order-taker arb scenario without executing it. - /// The caller provides their own exchange. Returns everything needed + /// Convenience overload where spender == pool. + function setup(Vm vm, address exchange, uint256 amount) internal returns (OrderTakerSetup memory) { + return setup(vm, exchange, exchange, amount); + } + + /// Set up an order-taker arb scenario without executing it. + /// The caller provides separate spender and pool addresses. + /// Input tokens are minted to the pool. Returns everything needed /// to call arb5 directly. /// /// @param vm The Vm cheatcode handle. - /// @param exchange The exchange contract address. + /// @param spender The address that receives the token approval. + /// @param pool The address that is called to execute the swap. /// @param amount Token amount for the swap (18 decimals). Used as /// raindexPullAmount, raindexOutputAmount, exchangeInputAmount, and swapAmount. - function setup(Vm vm, address exchange, uint256 amount) internal returns (OrderTakerSetup memory) { + function setup(Vm vm, address spender, address pool, uint256 amount) internal returns (OrderTakerSetup memory) { deployPrereqs(vm); MockToken inputToken = new MockToken("Input", "IN", 18); @@ -153,7 +161,7 @@ library LibTestArb { RealisticOrderTakerMockRaindex raindex = new RealisticOrderTakerMockRaindex(amount); outputToken.mint(address(raindex), amount); - inputToken.mint(exchange, amount); + inputToken.mint(pool, amount); GenericPoolRaindexV6ArbOrderTaker arb = new GenericPoolRaindexV6ArbOrderTaker(); @@ -188,7 +196,7 @@ library LibTestArb { maximumIORatio: LibDecimalFloat.packLossless(type(int224).max, 0), IOIsInput: true, orders: orders, - data: abi.encode(exchange, exchange, exchangeData) + data: abi.encode(spender, pool, exchangeData) }); } diff --git a/test/util/lib/LibTestFlashBorrowerArb.sol b/test/util/lib/LibTestFlashBorrowerArb.sol index 210a5a987d..dd18d18088 100644 --- a/test/util/lib/LibTestFlashBorrowerArb.sol +++ b/test/util/lib/LibTestFlashBorrowerArb.sol @@ -37,15 +37,23 @@ struct FlashBorrowerSetup { library LibTestFlashBorrowerArb { /// Set up a flash-borrower arb scenario without executing it. - /// The caller provides their own exchange contract. Returns everything - /// needed to call arb4 directly. + /// Convenience overload where spender == pool. + function setup(Vm vm, address exchange, uint256 amount) internal returns (FlashBorrowerSetup memory) { + return setup(vm, exchange, exchange, amount); + } + + /// Set up a flash-borrower arb scenario without executing it. + /// The caller provides separate spender and pool addresses for the + /// exchange data. Input tokens are minted to the pool. Returns + /// everything needed to call arb4 directly. /// /// @param vm The Vm cheatcode handle. - /// @param exchange The exchange contract address. + /// @param spender The address that receives the token approval. + /// @param pool The address that is called to execute the swap. /// @param amount Token amount for the swap (18 decimals). Used as /// exchangeInputAmount, swapAmount, and minimumIO (flash loan size). /// The raindex receives 10x amount of outputToken. - function setup(Vm vm, address exchange, uint256 amount) internal returns (FlashBorrowerSetup memory) { + function setup(Vm vm, address spender, address pool, uint256 amount) internal returns (FlashBorrowerSetup memory) { LibRainDeploy.etchZoltuFactory(vm); LibRainDeploy.deployZoltu(LibTOFUTokenDecimals.TOFU_DECIMALS_EXPECTED_CREATION_CODE); @@ -57,13 +65,13 @@ library LibTestFlashBorrowerArb { IRaindexV6 raindex = IRaindexV6(LibRaindexDeploy.RAINDEX_DEPLOYED_ADDRESS); outputToken.mint(address(raindex), 10 * amount); - inputToken.mint(exchange, amount); + inputToken.mint(pool, amount); GenericPoolRaindexV6FlashBorrower arb = new GenericPoolRaindexV6FlashBorrower(); bytes memory exchangeData = abi.encode( - exchange, - exchange, + spender, + pool, abi.encodeCall(MockExchange.swap, (IERC20(address(outputToken)), IERC20(address(inputToken)), amount)) ); From c97b44f104709f81831024b6dd5aa086bad54174 Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 20 May 2026 16:38:31 +0000 Subject: [PATCH 2/2] test: restore explanatory comment in testHashNotEqual Salvaged tests almost-perfectly but dropped a 1-line comment from the upstream audit commit. Restoring it so the new PR matches the original intent verbatim. --- test/lib/LibOrder.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/lib/LibOrder.t.sol b/test/lib/LibOrder.t.sol index a7fbfba446..1ca446684e 100644 --- a/test/lib/LibOrder.t.sol +++ b/test/lib/LibOrder.t.sol @@ -18,6 +18,7 @@ contract LibOrderTest is Test { /// Hashing should always produce different results for different inputs. /// forge-config: default.fuzz.runs = 100 function testHashNotEqual(OrderV4 memory a, OrderV4 memory b) public pure { + // Only test with actually different inputs. vm.assume(keccak256(abi.encode(a)) != keccak256(abi.encode(b))); assertTrue(LibOrder.hash(a) != LibOrder.hash(b)); }