5

Is it possible to detect, within a contract, that a function is executing in read-only mode?

Background:

Consider a modular system similar to Diamond pattern that uses a proxy-based forwarding scheme to send messages to implementation contracts. Consider that each implemented function has a corresponding role (1:1) handled by role-based access control. So far, so good, but it is undesirable for view and pure functions to have role-based guards. They can be open to everyone.

Can the proxy/forwarder detect that the message is read-only and harmless? If that can be detected, then the forwarder can safely skip the access-control guard.

    /***************************************************************************
     * Forward transactions
     ***************************************************************************/

    fallback() external {
        _fallback();
    }

    receive() external payable {
        _fallback();
    }

    function _fallback() private {

        bytes4 sel = msg.sig;

        // cannot call an uncontrolled function
        require(exists(sel));

        // must be authorized to call the function
        require(hasRole(getRole(sel), msg.sender)); <== unless this is a view/pure, but how to know?

        // carry on to DELEGATECALL 

Is there a way to check if we're in a read-only, view/pure context or a state-changing transaction?

4
  • Is there a reason why you don't check the roles in the implementation contracts instead of the proxy?
    – Undead8
    Commented Feb 15, 2022 at 2:37
  • 1
    You could set the gas to be very small. If It gets reverted, then it likely isn't view/pure.
    – FudgyDRS
    Commented Feb 15, 2022 at 5:59
  • 1
    @undead8 The implementations do set view, pure visibility. The proxy could use a data structure like mapping(bytes4 => bool) to "know" if access control can be safely skipped but I'd like to avoid that by using a logical expression of some kind, if possible. Commented Feb 15, 2022 at 9:00
  • It could be based on the method the caller is using (dry-run of mutating functions would be acceptable) or it could be based on detecting view and pure in the target functions of the implementation (which would not facilitate dry-run by non-authorized accounts). The thing is I don't know a good way to check for either case. Commented Feb 15, 2022 at 9:05

2 Answers 2

2

Sure it’s possible, would cost you a few hundreds of gas. The cheapest way I found is to try to emit event in external subcall to this.

First declare event and method:

event StaticCallCheck();

function staticCallChecker() external {
    require(msg.sender == address(this), "Access denied");
    emit StaticCallCheck();
}

Then try to externally call this method with try:

bool isStaticCall = false;
try this.staticCallChecker() {} catch {
    isStaticCall = true;
}

By the way, the difference between view and pure exists only in compile-time, it’s the same in runtime. So you can use a pure interface to call a view method and vice versa.

2
  • 1
    Although, this would likely work it would force you to update already existing contracts. Wouldn't it be more cost effective to see if the function ran with no gas?
    – FudgyDRS
    Commented Feb 16, 2022 at 2:37
  • Please see the answer below for discussion of this answer. Commented Feb 16, 2022 at 20:18
0

Responding to k06a

I see why the approach could work in principle but I'm unsuccessful getting it to work in practice.

This little experiment uses a hacky check (// HACK) to check the mode on the fly. When deployed like this, unexpected result:

  1. Deploy Implementation.sol
  2. Deploy Forwarder with Implementation.address
  3. Instantiate Implementation.at(Forwarder.address)

When require checks for staticCall and seeSomething is called (using Remix), it fails with that was not a static call.

pragma solidity 0.7.6;

// SPDX-License-Identifier: UNLICENSED

contract Implementation {

    uint public _nonce;

    function seeSomething() public view returns(address) {
        return address(this);
    }

    function saySomething() public returns(uint nonce) {
        nonce = _nonce++;
    }
}

contract Forwarder {

    address public implementation;

    event StaticCallCheck();

    constructor(address _implementation) {
        implementation = _implementation;
    }

    function staticCallChecker() external {
        require(msg.sender == address(this), "Access denied");
        emit StaticCallCheck();
    }

    function isStaticCall() public returns(bool isStatic) {
        try this.staticCallChecker() {} catch {
            isStatic = true;
        }
    }

    fallback() external {
        _fallback();
    }

    receive() external payable {
        _fallback();
    }

    function _fallback() private {

        // HACK
        require(isStaticCall(), "That was not a static call");
        // require(!isStaticCall(), "That was not a static call");

        address _implementation = address(implementation);

        uint256 value = msg.value;
        // solhint-disable-next-line no-inline-assembly
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := call(gas(), _implementation, value, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }      
    }
}

If both require statements under // HACK are commented out, no check, then the forwarder works as expected - happily returning view/pure values and updating the state when instructed to.

So, the forwarder is working and isStaticCall() is false in all cases.

Is there an implied requirement for the client to use a non-standard method?

9
  • I believe this happens because making a view call from Remix/web3 is not the same as making a static call from other Solidity code. Because node usually allows simulating non-view calls as view calls, I am afraid this is what happens even if the method is specified as the view - eth_call is not restricting context to be static.
    – k06a
    Commented Feb 16, 2022 at 20:41
  • So, if I understand correctly, this method can detect staticCall but not call, so a client needs to invoke the function in a special way and the default client behaviour for view/pure won't do the trick. The problem with that is it won't result in the desired behaviour with third party tools such as Etherscan since we can't dictate special methods. Commented Feb 16, 2022 at 22:39
  • This method will not detect eth_call, but will detect staticcall. To take care of eth_call you can use different method: twitter.com/0xkarmacoma/status/1493380279309717505 I would suggest to check msg.sender or tx.origin for being zero, checking gas price for being zero would also work fine for post-EIP-1559 chains.
    – k06a
    Commented Feb 17, 2022 at 23:12
  • Are you sure existing checks are not enough? Are trying to protect non-view execution with some roles management or trying to have some extra behavior for static calls? From my pov it makes no sense what is happening in eth_call, it could behave as non-view without any access checks - it’s still safe.
    – k06a
    Commented Feb 17, 2022 at 23:16
  • Opposite of that. We have a very DRY method of applying descrete access control to each function selector. Want to turn it off if the call is read-only so the view functions have no guards. Commented Feb 18, 2022 at 7:26

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.