Solidity – Understanding Dynamic Types in Calldata Encoding for Solidity and EVM

calldataencodingevmsolcsolidity

Context

I have recently been interested in how calldata is formed and interpreted. The Docs have a great section on how Dynamic Types are encoded into calldata. However, in practice I have trouble understanding this structure with more complex structs.


The Setup

I created two simple contracts to test how they encode and decode the struct. One contract simply forms a relatively complex struct (I used DyDx's ActionArgs for a "real" example)

Contract PassItToMe is simply used to receive the an array of action items. It doesn't do anything in particular, just lets me read the call data it receives.

Contract SimpleCallDataTest forms a hardcoded ActionArgs struct array and then calls PassItToMe's actionTest function, thats it.

Code Snippet

interface IPassItToMe {
    function actionTest(Actions.ActionArgs[] memory actions) external;
}

contract SimpleCallDataTest {

    function actionCallDataTest(address contAddr) public {
        Actions.ActionArgs[] memory operations = new Actions.ActionArgs[](1);

        operations[0] = Actions.ActionArgs({
            actionType: Actions.ActionType.Call,
            accountId: 7,
            amount: Types.AssetAmount({
                sign: true,
                denomination: Types.AssetDenomination.Wei,
                ref: Types.AssetReference.Delta,
                value: 1000
            }),
            primaryMarketId: 8,
            secondaryMarketId: 9,
            otherAddress: address(this),
            otherAccountId: 5,
            data: abi.encode(
                    msg.sender,
                    1000
                )
        });
        IPassItToMe(contAddr).actionTest(operations);
    }

}

Result

When we call the function actionCallDataTest, we can see the following is used as input to the CALL opcode.

The resulting calldata

Raw Hex

0x6a919f900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000009000000000000000000000000e0679aa631740f3dae1d3ce302f1f3d45f3ed61d00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000004000000000000000000000000031c8e1cb963d9cc02fcbc5d0cf881bbb1ffaf15f00000000000000000000000000000000000000000000000000000000000003e8

The Problem

When I format the input into the 32byte words to make it more human readable, we see the following:

Cleaned up calldata

In the above I have added notes to what my understanding what each word maps to and the byte count.

Issues:

  • Line 4: What is this value? it looks like an offset but I dont know
    what for.
  • Line 15: I understand that this is a offset for the start
    of the data attribute. Why is it 352? (0x160 = 352 decimal) I expected 448, as from this
    offset we can see the tail encoding for data

My Question

What do I misunderstand about the two above issues? The above encoding is correct and all data is correctly decoded by PassItToMe but I don't understand how.

Best Answer

From Function Selector and Argument Encoding:

All in all, a call to the function f with parameters a_1, ..., a_n is encoded as

function_selector(f) enc((a_1, ..., a_n))

Therefore, the function arguments are encoded as a tuple. In our case, we have:

function actionTest(Actions.ActionArgs[] memory actions) external;

So the arguments of actionTest are encoded as a tuple of length 1, i.e.:

(Actions.ActionArgs[] memory actions,)

From the Formal Specification of the ABI Encoding:

We distinguish static and dynamic types. Static types are encoded in-place and dynamic types are encoded at a separately allocated location after the current block.

Definition: The following types are called “dynamic”:

  1. bytes
  2. string
  3. T[] for any T
  4. T[k] for any dynamic T and any k >= 0
  5. (T1,...,Tk) if Ti is dynamic for some 1 <= i <= k

All other types are called “static”.

Since Actions.ActionArgs[] is dynamic (by rule 3), by rule 5 the tuple (Actions.ActionArgs[],) is also dynamic. Therefore actionTest’s argument-list of is encoded dynamically. Therefore at position 0x00 in the abi-encoded argument-list, we can find the position at which the first (and only) argument’s abi-encoded bytes will start.

Yes, it feels redundant, because for an argument-list of length 1, this value is always going to be 0x20. But in the general case, if there had been more arguments, the value would have differed.

Applying these rules, the bytes are decodable as follows:

0x6a919f90
args@0x000:                                                 0000000000000000000000000000000000000000000000000000000000000020 head(args[0]) = where tail(args[0]) starts within args
args@0x020: args[0]@0x000:                                  0000000000000000000000000000000000000000000000000000000000000001 actions.length
args@0x040: args[0]@0x020: actions@0x000:                   0000000000000000000000000000000000000000000000000000000000000020 head(actions[0]) = where tail(actions[0]) starts within actions
args@0x060: args[0]@0x040: actions@0x020: actions[0]@0x000: 0000000000000000000000000000000000000000000000000000000000000008 ActionType.Call
args@0x080: args[0]@0x060: actions@0x040: actions[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 accountId = 7
args@0x0a0: args[0]@0x080: actions@0x060: actions[0]@0x040: 0000000000000000000000000000000000000000000000000000000000000001 amount.sign = true
args@0x0c0: args[0]@0x0a0: actions@0x080: actions[0]@0x060: 0000000000000000000000000000000000000000000000000000000000000000 amount.denomination = AssetDenomination.Wei = 0
args@0x0e0: args[0]@0x0c0: actions@0x0a0: actions[0]@0x080: 0000000000000000000000000000000000000000000000000000000000000000 amount.ref = AssetReference.Delta = 0
args@0x100: args[0]@0x0e0: actions@0x0c0: actions[0]@0x0a0: 00000000000000000000000000000000000000000000000000000000000003e8 amount.value = 0x3e8 = 1000
args@0x120: args[0]@0x100: actions@0x0e0: actions[0]@0x0c0: 0000000000000000000000000000000000000000000000000000000000000008 primaryMarketId = 8
args@0x140: args[0]@0x120: actions@0x100: actions[0]@0x0e0: 0000000000000000000000000000000000000000000000000000000000000009 secondaryMarketId = 9
args@0x160: args[0]@0x140: actions@0x120: actions[0]@0x100: 000000000000000000000000e0679aa631740f3dae1d3ce302f1f3d45f3ed61d otherAddress
args@0x180: args[0]@0x160: actions@0x140: actions[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000005 otherAccountId = 5
args@0x1a0: args[0]@0x180: actions@0x160: actions[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000160 where data starts within actions[0]
args@0x1c0: args[0]@0x1a0: actions@0x180: actions[0]@0x160: 0000000000000000000000000000000000000000000000000000000000000040 byte-length of data = 64 bytes
args@0x1e0: args[0]@0x1c0: actions@0x1a0: actions[0]@0x180: 00000000000000000000000031c8e1cb963d9cc02fcbc5d0cf881bbb1ffaf15f msg.sender
args@0x200: args[0]@0x1e0: actions@0x1c0: actions[0]@0x1a0: 00000000000000000000000000000000000000000000000000000000000003e8 1000
Related Topic