0

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.

1 Answer 1

1

Updated reply

In my first response, I'm afraid I didn't read everything as carefully. And yes, your last edit has the correct answer. Low level calls like the one you use are made but it is up to the programmer to handle their execution. I.e. If they fail, they do so silently. Usually such calls are handled by creating variables out of the return data:

// ...
(bool success, bytes memory returnData) = payable(msg.sender).call{value: 1}("");
require(success, "Ether transfer failed");
// ...

Indeed, adding this handling to your contract above, it will revert the transaction. Without it, what is happening is when the call is made to the attacker and the attacker attempts to re-enter, the re-entrancy protection is stopping the re-entrancy and throwing a fail back to the caller, but this is discarded way at the top of the stack because the low-level call does not handle it. So the transaction is mined and the re-entrancy is guarded against.

3
  • I have made some edits. My question is about how Ethereum transactions works esp. regarding its atomicity rather than my code itself. I could not find answer on web. Dec 25, 2023 at 22:17
  • 1
    Won't this go to fallback function in absence of explicit receive function? Dec 26, 2023 at 8:15
  • You are correct. If there is a receive function, it will handle calls without calldata. A payable fallback function will handle it in the absence of a receive function. I've updated my comment to be more accurate.
    – red-swan
    Dec 27, 2023 at 4:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.