From a8c63306b5b956025695c4fab3a87e038333e6d4 Mon Sep 17 00:00:00 2001 From: thedavidmeister Date: Tue, 5 May 2026 13:32:55 +0400 Subject: [PATCH] test flow() atomicity on later-transfer failure Pins the documented atomicity invariant on `IFlowV5.flow()`: when a later transfer in the same flow reverts, the entire flow reverts and no earlier transfer's effects persist. The test mixes a valid ERC20 transfer with an ERC1155 transfer that fails the from-allowed check; the outer revert reason MUST be the inner failure's selector, which proves the inner revert was not caught and squashed. EVM transaction-level rollback handles the actual state revert; the test exists to lock the revert-propagation invariant against a future change that wraps inner calls in try/catch. Closes #318. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/src/concrete/Flow.transfer.t.sol | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/src/concrete/Flow.transfer.t.sol b/test/src/concrete/Flow.transfer.t.sol index 50c6e581..eefb703d 100644 --- a/test/src/concrete/Flow.transfer.t.sol +++ b/test/src/concrete/Flow.transfer.t.sol @@ -428,4 +428,53 @@ contract FlowTransferTest is FlowTest { flow.flow(evaluable, new uint256[](0), new SignedContextV1[](0)); vm.stopPrank(); } + + /// `IFlowV5.flow()` MUST process the flow atomically. When a later + /// transfer fails, the entire flow MUST revert and earlier transfers + /// must NOT have observable side effects. With mocks, we observe this + /// by asserting the outer revert (transaction revert rolls back any + /// state) and that the failing transfer's selector is the revert + /// reason — i.e. the inner failure was not caught and squashed. + /// forge-config: default.fuzz.runs = 100 + function testFlowAtomicRollbackOnLaterTransferFailure( + address alice, + address bob, + uint256 erc20Amount, + uint256 erc1155TokenId, + uint256 erc1155Amount + ) external { + vm.assume(alice != address(0)); + vm.assume(bob != alice); + vm.assume(Sentinel.unwrap(RAIN_FLOW_SENTINEL) != erc20Amount); + vm.assume(Sentinel.unwrap(RAIN_FLOW_SENTINEL) != erc1155TokenId); + vm.assume(Sentinel.unwrap(RAIN_FLOW_SENTINEL) != erc1155Amount); + vm.label(alice, "Alice"); + vm.label(bob, "Bob"); + + (IFlowV5 flow, EvaluableV2 memory evaluable) = deployFlow(); + assumeEtchable(alice, address(flow)); + assumeEtchable(bob, address(flow)); + + // ERC20 from alice → flow (would succeed); ERC1155 from bob → alice + // (will revert because `bob` is neither `msg.sender` nor `flow`). + ERC20Transfer[] memory erc20Transfers = new ERC20Transfer[](1); + erc20Transfers[0] = ERC20Transfer({token: TOKEN_A, from: alice, to: address(flow), amount: erc20Amount}); + + ERC1155Transfer[] memory erc1155Transfers = new ERC1155Transfer[](1); + erc1155Transfers[0] = + ERC1155Transfer({token: TOKEN_C, from: bob, to: alice, id: erc1155TokenId, amount: erc1155Amount}); + + uint256[] memory stack = LibStackGeneration.generateFlowStack( + Sentinel.unwrap(RAIN_FLOW_SENTINEL), + FlowTransferV1(erc20Transfers, new ERC721Transfer[](0), erc1155Transfers) + ); + interpreterEval2MockCall(stack, new uint256[](0)); + + vm.mockCall(TOKEN_A, abi.encodeWithSelector(IERC20.transferFrom.selector), abi.encode(true)); + + vm.startPrank(alice); + vm.expectRevert(UnsupportedERC1155Flow.selector); + flow.flow(evaluable, new uint256[](0), new SignedContextV1[](0)); + vm.stopPrank(); + } }