Skip to content

Commit a4c6df1

Browse files
Reject foreign onTakeOrders2 callers during arb5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e3ed129 commit a4c6df1

7 files changed

Lines changed: 178 additions & 27 deletions

‎src/abstract/RaindexV6ArbOrderTaker.sol‎

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {LibTOFUTokenDecimals} from "rain-tofu-erc20-decimals-0.1.4/src/lib/LibTO
1919
/// and calls it, so an untrusted address could drain the contract's balance.
2020
error BadRaindex(address badRaindex);
2121

22+
/// Thrown when `onTakeOrders2` is called during an arb by anything other than
23+
/// the trusted raindex.
24+
error UnexpectedOnTakeOrdersCaller(address caller);
25+
2226
/// @title RaindexV6ArbOrderTaker
2327
/// @notice Arb contract that takes orders directly from a `Raindex` without
2428
/// flash loans. Inheritors implement the strategy for sourcing the input token
@@ -75,19 +79,24 @@ abstract contract RaindexV6ArbOrderTaker is
7579
}
7680

7781
/// @inheritdoc IRaindexV6OrderTaker
78-
/// @dev Empty no-op, and intentionally permissionless: the contract holds no
79-
/// value between operations, so there is nothing to protect via `msg.sender`
80-
/// validation.
82+
/// @dev While `arb5` is in flight only the trusted raindex may call this;
83+
/// any other caller reverts with `UnexpectedOnTakeOrdersCaller`. During an
84+
/// arb the contract holds the caller's `msg.value` and tokens, and
85+
/// `takeOrders4` runs order-owner-chosen code (e.g. the order's store) that
86+
/// could otherwise re-enter here and direct that value.
8187
///
82-
/// The permissionless entry is also the recovery path for value that reaches
83-
/// the contract by accident. Unsolicited transfers cannot be prevented: ERC20
84-
/// transfers have no recipient hook to revert in, and the arb legitimately
85-
/// receives ERC20 and ETH mid-operation. Because anyone may call the
86-
/// take-orders entrypoints, a stray balance is swept out by the next call
87-
/// rather than stranded; gating this hook to an active orderbook callback
88-
/// would instead brick such balances permanently. Concrete implementations
89-
/// that move value here (e.g. the generic-pool variant's unlimited
90-
/// approve-and-call with whole-ETH-balance forward) are safe only under this
91-
/// no-value-between-operations invariant.
92-
function onTakeOrders2(address, address, Float, Float, bytes calldata) public virtual override {}
88+
/// Outside an arb it is a permissionless no-op: the contract holds no value
89+
/// between operations, so there is nothing to protect. The permissionless
90+
/// entry is the recovery path for value that reaches the contract by
91+
/// accident. Unsolicited transfers cannot be prevented: ERC20 transfers have
92+
/// no recipient hook to revert in, and the arb legitimately receives ERC20
93+
/// and ETH mid-operation. A stray balance is swept out by the next call
94+
/// rather than stranded. Concrete implementations that move value here (e.g.
95+
/// the generic-pool variant's unlimited approve-and-call with whole-ETH-
96+
/// balance forward) rely on both properties.
97+
function onTakeOrders2(address, address, Float, Float, bytes calldata) public virtual override {
98+
if (_reentrancyGuardEntered() && msg.sender != LibRaindexDeploy.RAINDEX_DEPLOYED_ADDRESS) {
99+
revert UnexpectedOnTakeOrdersCaller(msg.sender);
100+
}
101+
}
93102
}

‎src/generated/candidate/GenericPoolRaindexV6ArbOrderTaker.sol‎

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

‎src/generated/candidate/RouteProcessorRaindexV6ArbOrderTaker.sol‎

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

‎test/abstract/RaindexV6ArbOrderTaker.onTakeOrders2Base.t.sol‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import {LibRainDeploy} from "rain-deploy-0.1.22/src/lib/LibRainDeploy.sol";
1111
import {LibTOFUTokenDecimals} from "rain-tofu-erc20-decimals-0.1.4/src/lib/LibTOFUTokenDecimals.sol";
1212
import {MockToken} from "test/util/concrete/MockToken.sol";
1313

14-
/// The abstract `RaindexV6ArbOrderTaker.onTakeOrders2` body is a pure no-op:
14+
/// Outside an arb the abstract `RaindexV6ArbOrderTaker.onTakeOrders2` body is a no-op:
1515
/// it neither reverts nor touches state, and it does NOT decode or act on its
1616
/// `bytes` argument. A non-overriding child (`ChildRaindexV6ArbOrderTaker`)
1717
/// exercises the base directly.
1818
contract RaindexV6ArbOrderTakerOnTakeOrders2BaseTest is Test {
19-
/// The base no-op does nothing: it succeeds for ANY caller and ANY
19+
/// Outside an arb the base no-op does nothing: it succeeds for ANY caller and ANY
2020
/// arguments (including non-decodable garbage `bytes`), and leaves token
2121
/// balances untouched. Inserting a `revert` into the base body, or making
2222
/// it decode/act on the data, breaks this.

‎test/abstract/RaindexV6ArbOrderTaker.onTakeOrders2Direct.t.sol‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {LibTOFUTokenDecimals} from "rain-tofu-erc20-decimals-0.1.4/src/lib/LibTO
1111
import {MockToken} from "test/util/concrete/MockToken.sol";
1212

1313
contract RaindexV6ArbOrderTakerOnTakeOrders2DirectTest is Test {
14-
/// Calling onTakeOrders2 directly MUST succeed (no-op) and not modify
15-
/// any state. Anyone can call it — there is no access control by design.
14+
/// Calling onTakeOrders2 directly outside an arb MUST succeed (no-op) and not modify
15+
/// any state, whoever the caller.
1616
function testOnTakeOrders2DirectCallSucceeds() external {
1717
LibRainDeploy.etchZoltuFactory(vm);
1818
LibRainDeploy.deployZoltu(LibTOFUTokenDecimals.TOFU_DECIMALS_EXPECTED_CREATION_CODE);
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-License-Identifier: LicenseRef-DCL-1.0
2+
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
3+
pragma solidity =0.8.25;
4+
5+
import {RaindexV6ExternalRealTest} from "test/util/abstract/RaindexV6ExternalRealTest.sol";
6+
import {MockToken} from "test/util/concrete/MockToken.sol";
7+
import {LibTestTakeOrder} from "test/util/lib/LibTestTakeOrder.sol";
8+
import {LibTestArb} from "test/util/lib/LibTestArb.sol";
9+
import {GenericPoolRaindexV6ArbOrderTaker} from "../../../src/concrete/arb/GenericPoolRaindexV6ArbOrderTaker.sol";
10+
import {UnexpectedOnTakeOrdersCaller} from "../../../src/abstract/RaindexV6ArbOrderTaker.sol";
11+
import {
12+
OrderV4,
13+
OrderConfigV4,
14+
IOV2,
15+
EvaluableV4,
16+
TaskV2,
17+
TakeOrdersConfigV5
18+
} from "raindex-interface-0.1.8/src/interface/IRaindexV6.sol";
19+
import {IInterpreterV4, StateNamespace} from "rainlang-interface-0.2.13/src/interface/IInterpreterV4.sol";
20+
import {IInterpreterStoreV3} from "rainlang-interface-0.2.13/src/interface/IInterpreterStoreV3.sol";
21+
import {TEST_INTERPRETER_ADDRESS} from "rainlang-0.2.19/test/lib/deploy/LibTestInterpreterDeploy.sol";
22+
import {Float, LibDecimalFloat} from "rain-math-float-0.3.10/src/lib/LibDecimalFloat.sol";
23+
24+
/// Store that, on `set`, calls the arb's `onTakeOrders2` naming itself as
25+
/// spender and pool, and records the revert data.
26+
contract ReentrantOnTakeOrdersStore {
27+
address payable public arb;
28+
address public token;
29+
bytes public revertData;
30+
31+
function arm(address payable arb_, address token_) external {
32+
arb = arb_;
33+
token = token_;
34+
}
35+
36+
function set(StateNamespace, bytes32[] calldata) external {
37+
try GenericPoolRaindexV6ArbOrderTaker(arb)
38+
.onTakeOrders2(
39+
token, token, Float.wrap(0), Float.wrap(0), abi.encode(address(this), address(this), bytes(""))
40+
) {}
41+
catch (bytes memory err) {
42+
revertData = err;
43+
}
44+
}
45+
46+
function get(bytes32, bytes32) external pure returns (bytes32) {
47+
return 0;
48+
}
49+
50+
fallback() external payable {}
51+
receive() external payable {}
52+
}
53+
54+
/// Takes `amount` tokenIn from the caller, mints `amount` tokenOut to it and
55+
/// refunds any ETH sent.
56+
contract OneToOnePool {
57+
function swap(MockToken tokenIn, MockToken tokenOut, uint256 amount) external payable {
58+
tokenIn.transferFrom(msg.sender, address(this), amount);
59+
tokenOut.mint(msg.sender, amount);
60+
if (msg.value > 0) payable(msg.sender).transfer(msg.value);
61+
}
62+
}
63+
64+
contract EthSink {
65+
receive() external payable {}
66+
fallback() external payable {}
67+
}
68+
69+
contract GenericPoolRaindexV6ArbOrderTakerReentrantCallbackTest is RaindexV6ExternalRealTest {
70+
address internal owner = address(uint160(uint256(keccak256("owner.reentrant.callback"))));
71+
address internal bot = address(uint160(uint256(keccak256("bot.reentrant.callback"))));
72+
73+
/// An order's store re-entering `onTakeOrders2` during `arb5` is rejected,
74+
/// the arb completes and the caller's ETH is refunded to the caller.
75+
function testOrderStoreReentrantCallbackCannotTakeArbEth() external {
76+
GenericPoolRaindexV6ArbOrderTaker arb = new GenericPoolRaindexV6ArbOrderTaker();
77+
MockToken token0 = new MockToken("T0", "T0", 18);
78+
MockToken token1 = new MockToken("T1", "T1", 18);
79+
ReentrantOnTakeOrdersStore store = new ReentrantOnTakeOrdersStore();
80+
store.arm(payable(address(arb)), address(token0));
81+
OneToOnePool pool = new OneToOnePool();
82+
83+
token1.mint(owner, 10e18);
84+
vm.startPrank(owner);
85+
token1.approve(address(iRaindex), 10e18);
86+
iRaindex.deposit4(address(token1), bytes32(uint256(1)), LibDecimalFloat.packLossless(10, 0), new TaskV2[](0));
87+
vm.stopPrank();
88+
89+
IOV2[] memory ins = new IOV2[](1);
90+
ins[0] = IOV2(address(token0), bytes32(uint256(1)));
91+
IOV2[] memory outs = new IOV2[](1);
92+
outs[0] = IOV2(address(token1), bytes32(uint256(1)));
93+
OrderConfigV4 memory cfg = OrderConfigV4(
94+
EvaluableV4(
95+
IInterpreterV4(TEST_INTERPRETER_ADDRESS),
96+
IInterpreterStoreV3(address(store)),
97+
iParserV2.parse2(":set(1 2),\n_ _: 1 1;:;")
98+
),
99+
ins,
100+
outs,
101+
bytes32(0),
102+
bytes32(0),
103+
""
104+
);
105+
vm.recordLogs();
106+
vm.prank(owner);
107+
iRaindex.addOrder4(cfg, new TaskV2[](0));
108+
OrderV4 memory order = LibTestTakeOrder.extractOrderFromLogs(vm.getRecordedLogs());
109+
110+
TakeOrdersConfigV5 memory take = LibTestTakeOrder.defaultTakeConfig(LibTestTakeOrder.wrapSingle(order));
111+
take.maximumIO = LibDecimalFloat.packLossless(1, 0);
112+
take.data = abi.encode(address(pool), address(pool), abi.encodeCall(OneToOnePool.swap, (token1, token0, 1e18)));
113+
114+
vm.deal(bot, 1 ether);
115+
vm.prank(bot);
116+
arb.arb5{value: 1 ether}(iRaindex, take, LibTestArb.noopTask());
117+
118+
assertEq(store.revertData(), abi.encodeWithSelector(UnexpectedOnTakeOrdersCaller.selector, address(store)));
119+
assertEq(address(store).balance, 0);
120+
assertEq(bot.balance, 1 ether);
121+
assertEq(token1.balanceOf(address(pool)), 1e18);
122+
assertEq(token0.balanceOf(owner), 0);
123+
assertEq(address(arb).balance, 0);
124+
}
125+
126+
/// Outside an arb, anyone may call `onTakeOrders2` and it forwards a stray
127+
/// ETH balance to the named pool.
128+
function testOnTakeOrders2OutsideArbSweepsStrayEth(address caller) external {
129+
GenericPoolRaindexV6ArbOrderTaker arb = new GenericPoolRaindexV6ArbOrderTaker();
130+
MockToken token = new MockToken("T", "T", 18);
131+
EthSink sink = new EthSink();
132+
vm.deal(address(arb), 1 ether);
133+
134+
vm.prank(caller);
135+
arb.onTakeOrders2(
136+
address(token), address(token), Float.wrap(0), Float.wrap(0), abi.encode(address(sink), address(sink), "")
137+
);
138+
139+
assertEq(address(sink).balance, 1 ether);
140+
assertEq(address(arb).balance, 0);
141+
}
142+
}

‎test/concrete/arb/RouteProcessorRaindexV6ArbOrderTaker.onTakeOrders2Direct.t.sol‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {MockRouteProcessor} from "test/util/concrete/MockRouteProcessor.sol";
1414

1515
contract RouteProcessorRaindexV6ArbOrderTakerOnTakeOrders2DirectTest is Test {
1616
/// Calling onTakeOrders2 directly from an arbitrary address MUST succeed.
17-
/// The function has no access control by design — the contract is stateless
17+
/// Outside an arb the function has no access control — the contract is stateless
1818
/// between operations so there is nothing to exploit.
1919
function testOnTakeOrders2DirectCallByAttacker() external {
2020
LibRainDeploy.etchZoltuFactory(vm);

0 commit comments

Comments
 (0)