diff --git a/test/concrete/MaliciousReenteringToken.sol b/test/concrete/MaliciousReenteringToken.sol new file mode 100644 index 00000000..96994802 --- /dev/null +++ b/test/concrete/MaliciousReenteringToken.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IFlowV5} from "src/interface/IFlowV5.sol"; +import {EvaluableV2} from "rain.interpreter.interface/lib/caller/LibEvaluable.sol"; +import {SignedContextV1} from "rain.interpreter.interface/interface/IInterpreterCallerV2.sol"; + +/// An ERC20-shaped token whose `transferFrom` callback re-enters +/// `flow.flow(...)` on the same flow contract. Used to exercise the +/// `nonReentrant` guard on `Flow.flow`. +contract MaliciousReenteringToken { + IFlowV5 internal immutable I_FLOW; + EvaluableV2 internal evaluable; + + constructor(IFlowV5 flow_) { + I_FLOW = flow_; + } + + function setEvaluable(EvaluableV2 memory ev) external { + evaluable = ev; + } + + /// Re-enters `flow.flow` from inside the ERC20 `transferFrom` path. If + /// `Flow.flow`'s `nonReentrant` guard is in place the inner call will + /// revert with `"ReentrancyGuard: reentrant call"` and bubble up. + //forge-lint: disable-next-line(mixed-case-function) + function transferFrom(address, address, uint256) external returns (bool) { + I_FLOW.flow(evaluable, new uint256[](0), new SignedContextV1[](0)); + return true; + } + + function transfer(address, uint256) external pure returns (bool) { + return true; + } +} diff --git a/test/src/concrete/Flow.transfer.t.sol b/test/src/concrete/Flow.transfer.t.sol index 50c6e581..05ba7772 100644 --- a/test/src/concrete/Flow.transfer.t.sol +++ b/test/src/concrete/Flow.transfer.t.sol @@ -29,6 +29,7 @@ import {LibContextWrapper} from "test/lib/LibContextWrapper.sol"; import {IERC721} from "openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; import {IERC1155} from "openzeppelin-contracts/contracts/token/ERC1155/IERC1155.sol"; import {LibStackGeneration} from "test/lib/LibStackGeneration.sol"; +import {MaliciousReenteringToken} from "test/concrete/MaliciousReenteringToken.sol"; /// `IERC721.safeTransferFrom` is overloaded (3-arg + 4-arg). Pin the 3-arg /// selector via a single-overload wrapper interface so the disambiguation @@ -428,4 +429,36 @@ contract FlowTransferTest is FlowTest { flow.flow(evaluable, new uint256[](0), new SignedContextV1[](0)); vm.stopPrank(); } + + /// `Flow.flow` carries `nonReentrant`. A malicious ERC20 token whose + /// `transferFrom` re-enters `flow.flow(...)` MUST cause the inner call + /// to revert with the OZ ReentrancyGuardUpgradeable v4 string. This + /// pins the guard against a future change that drops `nonReentrant`. + /// forge-config: default.fuzz.runs = 100 + function testFlowReentrancyGuardFiresOnTokenCallback(address alice, uint256 amount) external { + vm.assume(alice != address(0)); + vm.assume(Sentinel.unwrap(RAIN_FLOW_SENTINEL) != amount); + vm.label(alice, "Alice"); + + (IFlowV5 flow, EvaluableV2 memory evaluable) = deployFlow(); + assumeEtchable(alice, address(flow)); + + MaliciousReenteringToken malToken = new MaliciousReenteringToken(flow); + malToken.setEvaluable(evaluable); + + ERC20Transfer[] memory erc20Transfers = new ERC20Transfer[](1); + erc20Transfers[0] = + ERC20Transfer({token: address(malToken), from: alice, to: address(flow), amount: amount}); + + uint256[] memory stack = LibStackGeneration.generateFlowStack( + Sentinel.unwrap(RAIN_FLOW_SENTINEL), + FlowTransferV1(erc20Transfers, new ERC721Transfer[](0), new ERC1155Transfer[](0)) + ); + interpreterEval2MockCall(stack, new uint256[](0)); + + vm.startPrank(alice); + vm.expectRevert(bytes("ReentrancyGuard: reentrant call")); + flow.flow(evaluable, new uint256[](0), new SignedContextV1[](0)); + vm.stopPrank(); + } }