The Resurrection of LLL: Part V

In part 4 of this series I discussed my views on code clarity and then got into the dispatcher's CODE section. We only got halfway through the dispatcher's effective constructor, so let's see if we can't get a lot further in this article. To start, we'll finish examining the dispatcher's constructor, but to do so we need to understand how to call another contract. For this instalment we'll be referring extensively to dispatcher.lll, often in the abstract, so you may want to have it open on the table next to you.

delegatecall

delegatecall is an important LLL keyword for our dispatcher. There are actually three call-type keywords available: call, callcode, and delegatecall. According to the documentation, delegatecall

is identical to a message call apart from the fact that the code at the target address is executed in the context of the calling contract and msg.sender and msg.value do not change their values.

That requires some deconstruction. Also, we're getting ahead of ourselves. In order for our dispatcher to work at all, it has to be able to call its associated contract. If we used a simple call, the called contract would generally work as expected, with any storage and memory belonging to that contract. But when it came time to replace the contract with another one for whatever reason, all storage and memory data would be lost. The new contract would start fresh with its own storage and memory. Since the dispatcher is in effect representing its contract to the blockchain, it makes no sense to have it lose all data just because the associated contract was swapped out. This is where delegatecall comes in, and to a lesser extent, callcode.

Recalling the partial definition above, when delegatecall is used instead of call, the associated contract is called in the context of the dispatcher. This means that when a contract's function is invoked, it's actually using the dispatcher's storage and memory; any changes it makes persist as long as the dispatcher exists. And since there's no provision to replace the dispatcher itself, it's effectively permanent.

With all of that in mind, we can now examine the rest of the dispatcher. After the contract address initialization, the Dispatcher(address) function calls the associated contract's initialize() function. This allows that contract to set up its function return sizes, along with anything else it wants to do at that point.

The first thing to do when calling another contract is to set up the call data. In this case it's a fairly simple process as we aren't passing any data along. All we need to do is get the initialize() function's ID into the first four bytes of the call data. This call data has to be stored in memory for delegatecall:

(mstore call-data (pad-right initialize))

That's quite straightforward now. pad-right takes the initialize function ID and slides it to the left, padding to the right with 28 zeroes. The result is simply stored at memory location call-data, which resolves to 0xa0 in constants.lll. That's all we need to do to set up the call data.

Next, we execute the delegatecall. This keyword takes six parameters: the amount of gas you're willing to spend on the call, the address of the contract you want to call, the memory location and length of the call data, and the memory location and length of the return data.

For the gas calculation we simply subtract 1000 from the amount of gas the contract has available. This leaves gas remaining for the execution of the rest of the dispatcher in the event that the called contract uses up all gas supplied. It would obviously be counterproductive to knowingly cause an out-of-gas exception (OOG) by supplying all gas available, preventing the dispatcher from continuing. We can specify such a high gas limit because, as the yellow paper states,

… for accounts with trusted code associated, a relatively high gas limit may be set and left alone.

Since we implicitly trust the associated contract's code we can set a high gas limit to avoid OOG exceptions. 1000 gas is about 0.00002 ether (at a common gas price), so we're dealing with very small amounts of ether in general. The gas supplied to a contract for its execution is usually much higher than 1000 gas, so we're not in danger of causing an OOG just by doing the delegatecall.

One thing worth noting is that unused gas from any of the call functions is returned to the caller under normal circumstances. The actual gas consumed will generally be quite miniscule. Gas is mainly used to prevent malicious or incorrect code from causing infinite loops, for example. With gas being consumed, a loop like that would eventually deplete its supply of gas and cause an OOG exception.

Continuing on with the parameters to delegatecall, we supply the address of the associated contract with @@contract-address. Next, we give the memory address of the call data we prepared previously and its length, in this case 32 bytes. Finally, we indicate the memory address into which we want delegatecall to place any returned data as well as that data's length. In this case we don't expect to receive anything from the call, but it still needs to be specified in the delegatecall parameters. I could have given zero for both parameters but there's something to be said for consistency. Specifying return-data makes it clear what the parameter is, easing the effort required to understand the code.

Code clarity redux

You'll notice that the delegatecall command spans two lines in the source:

(delegatecall (- (gas) 10000) @@contract-address
    call-data 32 return-data 0)

lllc doesn't care whether your entire contract is written on one line. It only cares about matching parentheses. But as humans, indentation clarifies the code structure for us. I subscribe to the view that keeping line length short makes reading source easier. In this case I broke delegatecall into two lines, as the one line exceeded 80 characters. It would have been just as valid to do this:

(delegatecall (- (gas) 10000) @@contract-address call-data 32 return-data 0)

And you're perfectly free to do so. But for this series of articles you'll have to suffer my views on code formatting.

Function invocation

Once the initialization call has been made we're finished with the dispatcher's pseudo-constructor. But if the previous test for the dispatcher function ID failed, execution of the constructor will have been skipped. In that case code execution continues after the constructor code. Since we're now at the point where we're potentially calling a function on our associated contract, we need to make sure that we have an associated contract. The test for a contract address of 0x00 ensures that we don't continue on without a contract to call. As described in part 2 of this series, we jump to an invalid location in order to cause an EVM exception, halting contract execution.

After that test we're in familiar territory again, employing delegatecall to call a function on the associated contract. It's slightly more complex this time, but it's not too bad. I'll skip a lot of the details, having just covered them in our discussion of calling the initialize() function.

The ID of the function we want to call is needed by the return-size macro, so we start by storing it in memory at short-hash. This time we don't need to construct the call data as it's already been supplied by the caller. All we need to do is copy that call data to a known memory location. That's what calldatacopy is for. We simply tell it to copy all supplied data to the call-data memory location, starting at offset 0x00 into the call data:

(calldatacopy call-data 0x00 (calldatasize))

delegatecall returns a boolean from the called contract indicating whether the call succeeded or failed. In order to later determine what to do on return we store this boolean at return-code. This invocation of delegatecall is very similar to the previous call with the exception that we're specifying a call data size that equals the size of the data passed in, using calldatasize. Also, the return data size is specified by the execution of the return-size macro, described at length in part 3.

Post-invocation

Upon return from delegatecall we need to determine whether the call succeeded or failed and act accordingly. If it succeeded we return any data coming in from the called contract. If there's no expected data to return we just stop contract execution with (stop) (which, if you dig very deeply, you'll find equates exactly to (return 0x00 0)). If delegatecall failed we halt execution with an exception, effectively propagating the EVM exception up to the caller.

Conclusion

With that we're finished our extensive investigation of the contract dispatcher and its support files. In the next part I'll present an example contract that can be called by the dispatcher, discussing what needs to be implemented by the contract in order to be compatible with the dispatcher. In future articles I'll go over how to deploy these contracts and subsequently call them, both via RPC calls and through web3. We're in the home stretch!