Skip to content

Commit 785b652

Browse files
Merge pull request #2912 from rainlanguage/2026-10-23-clear-zero-amount-per-side
clear3: refuse a side take would skip (zero output / negative ratio)
2 parents cf9adc4 + f33ab2e commit 785b652

8 files changed

Lines changed: 159 additions & 43 deletions

File tree

‎crates/test_fixtures/abis/RaindexV6.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎src/concrete/raindex/RaindexV6.sol‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,17 @@ contract RaindexV6 is IRaindexV6, IMetaV1_2, ReentrancyGuard, Multicall, Raindex
628628
new bytes32[](0)
629629
);
630630

631+
// Each side must be one `takeOrders4` would take. Given this, each
632+
// computed output is positive or the Float math reverts.
633+
if (
634+
!aliceOrderIOCalculation.outputMax.gt(LibDecimalFloat.FLOAT_ZERO)
635+
|| !bobOrderIOCalculation.outputMax.gt(LibDecimalFloat.FLOAT_ZERO)
636+
|| aliceOrderIOCalculation.IORatio.lt(LibDecimalFloat.FLOAT_ZERO)
637+
|| bobOrderIOCalculation.IORatio.lt(LibDecimalFloat.FLOAT_ZERO)
638+
) {
639+
revert ClearZeroAmount();
640+
}
641+
631642
ClearStateChangeV2 memory clearStateChange =
632643
calculateClearStateChange(aliceOrderIOCalculation, bobOrderIOCalculation);
633644

@@ -672,14 +683,6 @@ contract RaindexV6 is IRaindexV6, IMetaV1_2, ReentrancyGuard, Multicall, Raindex
672683

673684
handleIO(aliceOrderIOCalculation);
674685
handleIO(bobOrderIOCalculation);
675-
676-
// Do this last so we don't swallow errors from the handle IO.
677-
// Slither false positive. clearStateChange is a memory variable that
678-
// cannot be modified by reentrancy, and clear3 is nonReentrant.
679-
//slither-disable-next-line reentrancy-balance
680-
if (clearStateChange.aliceOutput.isZero() && clearStateChange.bobOutput.isZero()) {
681-
revert ClearZeroAmount();
682-
}
683686
}
684687

685688
/// Main entrypoint into an order calculates the amount and IO ratio. Both

‎src/error/ErrRaindexV6.sol‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ error NegativePush();
4444
/// Throws when a negative bounty is calculated.
4545
error NegativeBounty();
4646

47-
/// Thrown when clear output amounts are both zero.
47+
/// Thrown when either side of a clear has a non-positive output or a negative
48+
/// IO ratio.
4849
error ClearZeroAmount();

‎src/generated/candidate/GenericPoolRaindexV6ArbOrderTaker.sol‎

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

‎src/generated/candidate/GenericPoolRaindexV6FlashBorrower.sol‎

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

‎src/generated/candidate/RaindexV6.sol‎

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

‎src/generated/candidate/RouteProcessorRaindexV6ArbOrderTaker.sol‎

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

‎test/concrete/raindex/RaindexV6.clear.freshDriver.t.sol‎

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pragma solidity =0.8.25;
44

55
import {Test, Vm} from "forge-std-1.17.0/src/Test.sol";
66
import {NegativeBounty, ClearZeroAmount} from "src/error/ErrRaindexV6.sol";
7+
import {ExponentUnderflow} from "rain-math-float-0.3.10/src/error/ErrDecimalFloat.sol";
78
import {
89
OrderV4,
910
OrderConfigV4,
@@ -23,6 +24,19 @@ import {MockToken} from "test/util/concrete/MockToken.sol";
2324
import {LibOrder} from "src/lib/LibOrder.sol";
2425
import {CalcInterpreter} from "test/util/concrete/CalcInterpreter.sol";
2526
import {IRaindexV6ClearDriverHarness} from "test/util/interface/IRaindexV6ClearDriverHarness.sol";
27+
import {IParserV2} from "rainlang-interface-0.2.13/src/interface/IParserV2.sol";
28+
import {FullyQualifiedNamespace} from "rainlang-interface-0.2.13/src/interface/IInterpreterStoreV3.sol";
29+
import {StateNamespace} from "rainlang-interface-0.2.13/src/interface/IInterpreterV4.sol";
30+
import {LibNamespace} from "rainlang-interface-0.2.13/src/lib/ns/LibNamespace.sol";
31+
import {
32+
LibTestInterpreterDeploy,
33+
TEST_INTERPRETER_ADDRESS,
34+
TEST_STORE_ADDRESS,
35+
TEST_EXPRESSION_DEPLOYER_ADDRESS
36+
} from "rainlang-0.2.19/test/lib/deploy/LibTestInterpreterDeploy.sol";
37+
import {
38+
CREATION_CODE as DECIMAL_FLOAT_CREATION_CODE
39+
} from "rain-math-float-deploy-0.1.7/src/generated/0_1_5/DecimalFloat.sol";
2640

2741
/// @title RaindexV6ClearFreshDriverTest
2842
/// @notice Drives the FULL `clear3` against fresh-compiled RaindexV6 bytecode so
@@ -287,30 +301,128 @@ contract RaindexV6ClearFreshDriverTest is Test {
287301
// ClearZeroAmount
288302
// ---------------------------------------------------------------------
289303

290-
/// Both orders evaluate to a zero max output -> both outputs are zero, so
291-
/// clear3 reverts ClearZeroAmount (after handle IO). Pins B12.
292-
function testFreshClearZeroAmountReverts() external {
293-
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) = wireOrders(f(1, 0), f(0, 0), f(1, 0), f(0, 0), f(10, 0));
304+
function expectClearZeroAmount(Float aliceRatio, Float aliceMax, Float bobRatio, Float bobMax) internal {
305+
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) =
306+
wireOrders(aliceRatio, aliceMax, bobRatio, bobMax, f(10, 0));
294307

295308
vm.prank(bountyBot);
296309
vm.expectRevert(ClearZeroAmount.selector);
297310
raindex.clear3(aliceOrder, bobOrder, clearConfig(), new SignedContextV1[](0), new SignedContextV1[](0));
298311
}
299312

300-
/// Only ONE side zero is NOT a zero clear: alice max 1, bob max 0. bobOutput
301-
/// is 0 but aliceOutput is 1, so the AND guard (both zero) does NOT trip and
302-
/// the clear succeeds. Pins that ClearZeroAmount requires BOTH outputs zero,
303-
/// not either.
304-
function testFreshClearOneSideZeroSucceeds() external {
305-
// Alice ratio 0 so aliceInput=0 (bobBounty = bobOutput - aliceInput stays
306-
// >= 0); bob max 0 so bobOutput=0, bobInput=0.
307-
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) = wireOrders(f(0, 0), f(1, 0), f(1, 0), f(0, 0), f(10, 0));
313+
function testFreshClearBothZeroOutputReverts() external {
314+
expectClearZeroAmount(f(1, 0), f(0, 0), f(1, 0), f(0, 0));
315+
}
316+
317+
function testFreshClearAliceZeroOutputReverts() external {
318+
expectClearZeroAmount(f(1, 0), f(0, 0), f(0, 0), f(1, 0));
319+
}
320+
321+
function testFreshClearBobZeroOutputReverts() external {
322+
expectClearZeroAmount(f(0, 0), f(1, 0), f(1, 0), f(0, 0));
323+
}
324+
325+
function testFreshClearAliceNegativeOutputReverts() external {
326+
expectClearZeroAmount(f(1, 0), f(-1, 0), f(1, 0), f(1, 0));
327+
}
328+
329+
function testFreshClearBobNegativeOutputReverts() external {
330+
expectClearZeroAmount(f(1, 0), f(1, 0), f(1, 0), f(-1, 0));
331+
}
332+
333+
function testFreshClearAliceNegativeRatioReverts() external {
334+
expectClearZeroAmount(f(-1, 0), f(1, 0), f(1, 0), f(1, 0));
335+
}
336+
337+
function testFreshClearBobNegativeRatioReverts() external {
338+
expectClearZeroAmount(f(1, 0), f(1, 0), f(-1, 0), f(1, 0));
339+
}
340+
341+
/// Alice's calculate sets a store key while her output is zero against a
342+
/// zero-ratio Bob; the refused clear leaves her store untouched.
343+
function testFreshClearAliceZeroOutputLeavesStoreUnchanged() external {
344+
LibRainDeploy.deployZoltu(DECIMAL_FLOAT_CREATION_CODE);
345+
LibTestInterpreterDeploy.etchTestRainlang(vm);
346+
IParserV2 parser = IParserV2(TEST_EXPRESSION_DEPLOYER_ADDRESS);
347+
IInterpreterStoreV3 store = IInterpreterStoreV3(TEST_STORE_ADDRESS);
348+
349+
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) = wireOrders(f(1, 0), f(1, 0), f(1, 0), f(1, 0), f(10, 0));
350+
aliceOrder.evaluable = EvaluableV4({
351+
interpreter: IInterpreterV4(TEST_INTERPRETER_ADDRESS),
352+
store: store,
353+
bytecode: parser.parse2(":set(1 2),_ _:0 1;:;")
354+
});
355+
bobOrder.evaluable = EvaluableV4({
356+
interpreter: IInterpreterV4(TEST_INTERPRETER_ADDRESS), store: store, bytecode: parser.parse2("_ _:1 0;:;")
357+
});
358+
raindex.setOrderLive(aliceOrder.hash());
359+
raindex.setOrderLive(bobOrder.hash());
360+
361+
FullyQualifiedNamespace aliceNamespace =
362+
LibNamespace.qualifyNamespace(StateNamespace.wrap(uint256(uint160(alice))), address(raindex));
363+
364+
vm.prank(bountyBot);
365+
vm.expectRevert(ClearZeroAmount.selector);
366+
raindex.clear3(aliceOrder, bobOrder, clearConfig(), new SignedContextV1[](0), new SignedContextV1[](0));
367+
368+
assertEq(store.get(aliceNamespace, bytes32(uint256(1))), bytes32(0), "alice store unchanged");
369+
}
370+
371+
/// A zero ratio against a positive counterparty clears: the zero-ratio side
372+
/// gives its output for nothing.
373+
function testFreshClearAliceZeroRatioClears() external {
374+
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) = wireOrders(f(0, 0), f(1, 0), f(1, 0), f(1, 0), f(10, 0));
308375

309376
doClear(aliceOrder, bobOrder);
310377

311-
// Alice output moved 1 out; bob output stayed.
312378
assertTrue(raindex.vaultBalance2(alice, address(token0), ALICE_OUT_VAULT).eq(f(9, 0)), "alice out 9");
313-
assertTrue(raindex.vaultBalance2(bob, address(token1), BOB_OUT_VAULT).eq(f(10, 0)), "bob out untouched");
379+
assertTrue(raindex.vaultBalance2(bob, address(token1), BOB_OUT_VAULT).eq(f(9, 0)), "bob out 9");
380+
assertTrue(raindex.vaultBalance2(alice, address(token1), ALICE_IN_VAULT).isZero(), "alice in 0");
381+
assertTrue(raindex.vaultBalance2(bob, address(token0), BOB_IN_VAULT).eq(f(1, 0)), "bob in 1");
382+
assertTrue(raindex.vaultBalance2(bountyBot, address(token1), BOB_BOUNTY_VAULT).eq(f(1, 0)), "bob bounty 1");
383+
}
384+
385+
function testFreshClearBobZeroRatioClears() external {
386+
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) = wireOrders(f(1, 0), f(1, 0), f(0, 0), f(1, 0), f(10, 0));
387+
388+
doClear(aliceOrder, bobOrder);
389+
390+
assertTrue(raindex.vaultBalance2(alice, address(token0), ALICE_OUT_VAULT).eq(f(9, 0)), "alice out 9");
391+
assertTrue(raindex.vaultBalance2(bob, address(token1), BOB_OUT_VAULT).eq(f(9, 0)), "bob out 9");
392+
assertTrue(raindex.vaultBalance2(alice, address(token1), ALICE_IN_VAULT).eq(f(1, 0)), "alice in 1");
393+
assertTrue(raindex.vaultBalance2(bob, address(token0), BOB_IN_VAULT).isZero(), "bob in 0");
394+
assertTrue(raindex.vaultBalance2(bountyBot, address(token0), ALICE_BOUNTY_VAULT).eq(f(1, 0)), "alice bounty 1");
395+
}
396+
397+
/// A zero ratio caps against a negative counterparty max, which would
398+
/// divide by that zero ratio if the sides were not refused first.
399+
function testFreshClearAliceZeroRatioBobNegativeOutputReverts() external {
400+
expectClearZeroAmount(f(0, 0), f(1, 0), f(1, 0), f(-1, 0));
401+
}
402+
403+
function testFreshClearBobZeroRatioAliceNegativeOutputReverts() external {
404+
expectClearZeroAmount(f(1, 0), f(-1, 0), f(0, 0), f(1, 0));
405+
}
406+
407+
/// Both sides pass the refusal but the capped output, the smallest max
408+
/// over the largest ratio, is below every non-zero Float: the division
409+
/// reverts rather than yielding a zero output.
410+
function testFreshClearAliceCappedOutputUnderflowReverts() external {
411+
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) =
412+
wireOrders(f(1, type(int32).max), f(1, 0), f(1, 0), f(1, type(int32).min), f(10, 0));
413+
414+
vm.prank(bountyBot);
415+
vm.expectPartialRevert(ExponentUnderflow.selector);
416+
raindex.clear3(aliceOrder, bobOrder, clearConfig(), new SignedContextV1[](0), new SignedContextV1[](0));
417+
}
418+
419+
function testFreshClearBobCappedOutputUnderflowReverts() external {
420+
(OrderV4 memory aliceOrder, OrderV4 memory bobOrder) =
421+
wireOrders(f(1, 0), f(1, type(int32).min), f(1, type(int32).max), f(1, 0), f(10, 0));
422+
423+
vm.prank(bountyBot);
424+
vm.expectPartialRevert(ExponentUnderflow.selector);
425+
raindex.clear3(aliceOrder, bobOrder, clearConfig(), new SignedContextV1[](0), new SignedContextV1[](0));
314426
}
315427

316428
// ---------------------------------------------------------------------

0 commit comments

Comments
 (0)