I tried investigating reentrancy and found something unexpected. I tried preventing reentrancy with reentrancy locks as recommended. When I ran the code, there was no error due to attempted reentrancy but the balance was unchanged, the attacker failed to draw balance. Hence the reentrancy locked failed the transfer to the buyer but there was no error for us.
So, does reentrancy create a new transaction flow or something that failed transfer does not cause entire thing to rollback? I checked the state in the main contract was updating.
If new transaction flow is created, it still checks the state according to the execution in VM instead of what is in the block otherwise reentrancy locks would not work. So at what point exactly does the new flow start? Does it happen every time one contract calls another? What about library calls? I checked that failed require statements in the library was still rolling back my transaction.
contract ProtectedContract {
bool internal reentrantLock = false;
constructor(){
}
uint256 public ncalls = 0;
modifier noReentrant {
require(!reentrantLock, "Reentrant detected");
reentrantLock = true;
_;
reentrantLock = false;
}
function deposit() external payable {
}
function externalContract() external {
ncalls += 1;
reEntrantPrevented();
}
function reEntrantPrevented() internal noReentrant {
payable(msg.sender).call{value: 1}("");
}
}
// Test reintrancy prevention
contract AttackerContract {
ProtectedContract contrant;
constructor(){
contrant = new ProtectedContract();
}
function deposit() external payable {
contrant.deposit{value:msg.value}();
}
function attack() external {
contrant.externalContract();
}
function ncalls() public view returns (uint256){
return contrant.ncalls();
}
fallback() external payable {
contrant.externalContract();
}
function protectedAddress() external view returns (address){
return address(contrant);
}
}
Here is my code. After I call attack(), the AttackerContract failed to get anything: I checked the balance it was 0. So the reentrancy check blocked the transfer. But ncalls in ProtectedContract became 1, which means that attack() function itself was not rolled back.
So how does this work? How are transactions created when one contract calls another? I mean failed require() on libraries seem to indeed roll back the whole thing. or is it just when called using call statement that it makes new transaction flow.
EDIT
After looking at some other answers on this site, I think I have an answer please let me know if I am right. Using direct calls on other contracts or libraries, if the callee reverts, the whole thing reverts. However, those called using "call" does not if it or any direct sub calls reverts, it does not roll back the caller contract.