1

It seems that almost all the examples of external calls are simple proxies, which do something like this:

    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 := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

        // ...
    }

What I am really struggling with is sending arbitrary data to the call() / delegatecall(), and these examples don't really help my understanding since the calldata is automatically forwarded to the call using the convenient calldata helper functions without having to do any messing with memory or the stack. I also don't think this method works if some memory was allocated previously, since this would be overwriting whatever was at offset 0 previously.

Looking at evm.codes, we see these are the arguments to delegatecall():

1. gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
2. address: the account which code to execute.
3. argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
4. argsSize: byte size to copy (size of the calldata).
5. retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
6. retSize: byte size to copy (size of the return data).

(3) and (4) are the ones I am concerned with here for writing arbitrary data.

Let's say I wanted to call this function on the implementation contract:

myFunction(bytes4,uint256) => 0x7e965aeb

with the appropriate calldata.

 

As I understand (3) byte offset in the memory in bytes, the calldata of the sub context, this means we need to pack the last n bytes of memory with bytes4, bytes4, uint256 (where the first bytes4 is the function sig to call, and the next two are the arguments). So this is how I would expect this to be done:

    bytes4 fnSig := bytes4("0x7e965aeb")
    bytes4 param1 := bytes4("0xc48d6d5e")

    assembly {
        let sig := fnSig
        let data1 := param1
        let data2 := 12345678

        let memStart := msize()
        mstore(sig)
        mstore(data1)
        mstore(data2)
        let result := delegatecall(gas(), implementation, memStart, add(4, add(4, 1), 0, 0)

        // ...
    }

However, the above doesn't seem to work, and I am not entirely sure why. Additionally, to avoid not overwriting existing memory, I use msize() which seems bad because I need to disable Yul optimizer to use this.

I think it's possible my understanding of how to load these arguments into memory is incorrect. I would greatly appreciate some guidance here!

1

1 Answer 1

4

There are several issues with your code :

  • You are not handling memory correctly
  • You are not encoding your parameters correctly
  • You are not computing the data size correctly

The delegatecall opcode will simply make a call to implementation giving argSize bytes of data starting at argOffset as the calldata for the callee. This call will take place in the caller's storage context, but that's irrelevant for your question.

this means we need to pack the last n bytes of memory with bytes4, bytes4, uint256 (where the first bytes4 is the function sig to call, and the next two are the arguments).

This data must contain the function identifier (4 bytes), the abi encoded param1 and the abi encoded param2. The function identifier is a way to identify which function should be called on the caller side, and the rest of that data (param1 and param2) are its parameters.

In solidity you would encode them in one of those way :

// Manually 
bytes memory encoded = abi.encodePacked(fnSig, abi.encode(param1, param2));

// With encodeWithSelector
bytes memory encoded = abi.encodeWithSelector(fnSig, param1, param2);

// With encodeWithSignature
bytes memory encoded = abi.encodeWithSignature("myFunction(bytes4,uint256)", param1, param2);

Giving a byte array with :

  • bytes 0 - 4 : Function identifier
  • bytes 4 - 36 : abi encoded param1 (padded to 32 bytes as per the abi specs)
  • bytes 36 - 68 : abi encoded param 2

For a total size of 68 (0x44) bytes.

0x7e965aebc48d6d5e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc614e

where :

  • 0x7e965aeb is the function identifier
  • 0xc48d6d5e00000000000000000000000000000000000000000000000000000000 is param1
  • 0x0000000000000000000000000000000000000000000000000000000000bc614e is param 2

Additionally, to avoid not overwriting existing memory, I use msize() which seems bad because I need to disable Yul optimizer to use this.

It's not bad per say, you can go around it and use an optimizer friendly approach relying only on the free memory pointer at address 0x40.

If that's useful here is a commented set of contracts showing the 3 main approach :

  • Encoding with solidity (caller)
  • Encoding with assembly / free memory pointer (caller2)
  • Encoding with assembly / msize (caller3)

The target contract will log the params so that you can see that the proper values were forwarded.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {

    event CalledSuccessfully(bytes4 param1, uint256 param2);

    function myFunction(bytes4 param1, uint256 param2) public {
        emit CalledSuccessfully(param1, param2);
    }
}

contract Delegator {

    Delegate delegate;

    constructor(Delegate _delegate) {
        delegate = _delegate;
    }

    function caller() public {
        bytes4 fnSig = hex"7e965aeb";
        bytes4 param1 = hex"c48d6d5e";
        uint256 param2 = 12345678;

        //bytes memory encoded = abi.encodePacked(fnSig, abi.encode(param1, param2));
        //bytes memory encoded = abi.encodeWithSelector(fnSig, param1, param2);
        bytes memory encoded = abi.encodeWithSignature("myFunction(bytes4,uint256)", param1, param2);

        assembly {

            // Get the value at slot `delegate.slot` to get the address of our target contract
            let implementation := sload(delegate.slot)

            // gas : gas()
            // address : implementation -> sload(delegate.slot)
            // argsOffset : add(encoded, 0x20) -> skip the size of the array
            // argsSize : mload(encoded) -> first element of the array is its size
            // retOffset : 0
            // retSize : 0
            let result := delegatecall(gas(), implementation, add(encoded, 0x20), mload(encoded), 0, 0)

            if eq(result, 0) {
                revert(0, returndatasize())
            }
        }
    }

     function caller2() public {
        bytes4 fnSig = hex"7e965aeb";
        bytes4 param1 = hex"c48d6d5e";
        uint256 param2 = 12345678;

        assembly {

            // Get the free memory address with the free memory pointer
            let params := mload(0x40)

            // We store 0x44 bytes, so we increment the free memory pointer
            // by that exact amount to keep things in order
            mstore(0x40, add(params, 0x44))

            // Store fnSig at params : here we store 32 bytes : 4 bytes of fnSig and 28 bytes of RIGHT padding
            mstore(params, fnSig)

            // Store param1 at params + 0x4 : overwriting the 28 bytes of RIGHT padding included before
            mstore(add(params, 0x4), param1)

            // Store param2 at params + 0x4 + 0x20 = 0x24 (36 bytes) : store 32 bytes following fnSig (4 bytes) and param1 (32 bytes)
            mstore(add(params, 0x24), param2)

            // Get the value at slot `delegate.slot` to get the address of our target contract
            let implementation := sload(delegate.slot)
            
            // gas : gas()
            // address : implementation -> sload(delegate.slot)
            // argsOffset : encoded : we are not dealing with a solidity byte array
            // argsSize : 0x44
            // retOffset : 0
            // retSize : 0
            let result := delegatecall(gas(), implementation, params, 0x44, 0, 0)

            if eq(result, 0) {
                revert(0, returndatasize())
            }

        }
    }

    function caller3() public {
        bytes4 fnSig = hex"7e965aeb";
        bytes4 param1 = hex"c48d6d5e";
        uint256 param2 = 12345678;

        assembly {
             // Get the free memory address with msize
            let params := msize()

            // We store 0x44 bytes, so we increment the free memory pointer
            // by that exact amount to keep things in order
            mstore(0x40, add(params, 0x44))

            // Store fnSig at params : here we store 32 bytes : 4 bytes of fnSig and 28 bytes of RIGHT padding
            mstore(params, fnSig)

            // Store param1 at params + 0x4 : overwriting the 28 bytes of RIGHT padding included before
            mstore(add(params, 0x4), param1)

             // Store param2 at params + 0x4 + 0x20 = 0x24 (36 bytes) : store 32 bytes following fnSig (4 bytes) and param1 (32 bytes)
            mstore(add(params, 0x24), param2)

            // Get the value at slot `delegate.slot` to get the address of our target contract
            let implementation := sload(delegate.slot)

            // gas : gas()
            // address : implementation -> sload(delegate.slot)
            // argsOffset : encoded : we are not dealing with a solidity byte array
            // argsSize : 0x44
            // retOffset : 0
            // retSize : 0
            let result := delegatecall(gas(), implementation, params, 0x44, 0, 0)

           if eq(result, 0) {
                revert(0, returndatasize())
            }
        }
    }
}

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.