Skip to main content

Nested Contract Calls

A nested contract call occurs during AVM execution and is triggered by a contract call instruction. The AVM instruction set includes three contract call instructions: CALL, STATICCALL, and DELEGATECALL.

A nested contract call performs the following operations:

  1. Charge gas for the nested call
  2. Trace the nested contract call
  3. Derive the nested context from the calling context and the call instruction
  4. Initiate AVM execution within the nested context until a halt is reached
  5. Update the calling context after the nested call halts

Or, in pseudocode:

// instr.args are { gasOffset, addrOffset, argsOffset, retOffset, retSize }

isStaticCall = instr.opcode == STATICCALL;
isDelegateCall = instr.opcode == DELEGATECALL;
l2GasCost = min(M[instr.args.gasOffset], context.machineState.l2GasLeft);
daGasCost = min(M[instr.args.gasOffset + 1], context.machineState.daGasLeft);

chargeGas(context, l2GasCost, daGasCost);
traceNestedCall(context, instr.args.addrOffset);
nestedContext = deriveContext(
context,
instr.args,
isStaticCall,
isDelegateCall
);
execute(nestedContext);
updateContextAfterNestedCall(context, instr.args, nestedContext);

These call instructions share the same argument definitions: gasOffset, addrOffset, argsOffset, argsSize, retOffset, retSize, and successOffset (defined in the instruction set). These arguments will be referred to via those keywords below, and will often be used in conjunction with the M[offset] syntax which is shorthand for context.machineState.memory[offset].

Tracing nested contract calls

Before nested execution begins, the contract call is traced.

traceNestedCall(context, addrOffset)
// which is shorthand for
context.worldStateAccessTrace.contractCalls.append(
TracedContractCall {
callPointer: context.worldStateAccessTrace.contractCalls.length + 1,
address: M[addrOffset],
storageAddress: M[addrOffset],
counter: ++context.worldStateAccessTrace.accessCounter,
endLifetime: 0, // The call's end-lifetime will be updated later if it or its caller reverts
}
)

Context initialization for nested calls

The nested call's execution context is derived from the caller's context and the call instruction's arguments.

The following shorthand syntax is used to refer to nested context derivation in the "Instruction Set" and other sections:

// instr.args are { gasOffset, addrOffset, argsOffset, retOffset, retSize }

isStaticCall = instr.opcode == STATICCALL
isDelegateCall = instr.opcode == DELEGATECALL

nestedContext = deriveContext(context, instr.args, isStaticCall, isDelegateCall)

Nested context derivation is defined as follows:

nestedExecutionEnvironment = ExecutionEnvironment {
address: M[addrOffset],
storageAddress: isDelegateCall ? context.storageAddress : M[addrOffset],
sender: isDelegateCall ? context.sender : context.address,
functionSelector: context.environment.functionSelector,
transactionFee: context.environment.transactionFee,
contractCallDepth: context.contractCallDepth + 1,
contractCallPointer: context.worldStateAccessTrace.contractCalls.length + 1,
globals: context.globals,
isStaticCall: isStaticCall,
isDelegateCall: isDelegateCall,
calldata: context.memory[M[argsOffset]:M[argsOffset]+argsSize],
}

nestedMachineState = MachineState {
l2GasLeft: context.machineState.memory[M[gasOffset]],
daGasLeft: context.machineState.memory[M[gasOffset+1]],
pc = 0,
internalCallStack = [], // initialized as empty
memory = [0, ..., 0], // all 2^32 entries are initialized to zero
}
nestedContext = AvmContext {
environment: nestedExecutionEnvironment,
machineState: nestedMachineState,
worldState: context.worldState,
worldStateAccessTrace: context.worldStateAccessTrace,
accruedSubstate: { [], ... [], }, // all empty
results: {reverted: false, output: []},
}

M[offset] notation is shorthand for context.machineState.memory[offset]

Gas cost of call instruction

A call instruction's gas cost is derived from its gasOffset argument. In other words, the caller "allocates" gas for a nested call via its gasOffset argument.

As with all instructions, gas is checked and cost is deducted prior to the instruction's execution.

chargeGas(context, l2GasCost, daGasCost);

The shorthand chargeGas is defined in "Gas checks and tracking".

As with all instructions, gas is checked and cost is deducted prior to the instruction's execution.

assert context.machineState.l2GasLeft - l2GasCost >= 0
assert context.machineState.daGasLeft - daGasCost >= 0
context.l2GasLeft -= l2GasCost
context.daGasLeft -= daGasCost

When the nested call halts, it may not have used up its entire gas allocation. Any unused gas is refunded to the caller as expanded on in "Updating the calling context after nested call halts".

Nested execution

Once the nested call's context is initialized, execution within that context begins.

execute(nestedContext);

Execution (and the execution shorthand above) is detailed in "Execution, Gas, Halting". Note that execution mutates the nested context.

Updating the calling context after nested call halts

After the nested call halts, the calling context is updated. The call's success is extracted, unused gas is refunded, output data can be copied to the caller's memory, world state and accrued substate are conditionally accepted, and the world state trace is updated. The following shorthand is used to refer to this process in the "Instruction Set":

updateContextAfterNestedCall(context, instr.args, nestedContext);

The caller checks whether the nested call succeeded, and places the answer in memory.

context.machineState.memory[instr.args.successOffset] =
!nestedContext.results.reverted;

Any unused gas is refunded to the caller.

context.l2GasLeft += nestedContext.machineState.l2GasLeft;
context.daGasLeft += nestedContext.machineState.daGasLeft;

If the call instruction specifies non-zero retSize, the caller copies any returned output data to its memory.

if retSize > 0:
context.machineState.memory[retOffset:retOffset+retSize] = nestedContext.results.output

If the nested call succeeded, the caller accepts its world state and accrued substate modifications.

if !nestedContext.results.reverted:
context.worldState = nestedContext.worldState
context.accruedSubstate.append(nestedContext.accruedSubstate)

Accepting nested call's World State access trace

If the nested call reverted, the caller initializes the "end-lifetime" of all world state accesses made within the nested call.

if nestedContext.results.reverted:
// process all traces (this is shorthand)
for trace in nestedContext.worldStateAccessTrace:
for access in trace:
if access.callPointer >= nestedContext.environment.callPointer:
// don't override end-lifetime already set by a deeper nested call
if access.endLifetime == 0:
access.endLifetime = nestedContext.worldStateAccessTrace.accessCounter

A world state access that was made in a deeper nested reverted context will already have its end-lifetime initialized. The caller does not overwrite this access' end-lifetime here as it already has a narrower lifetime.

Regardless of whether the nested call reverted, the caller accepts its updated world state access trace (with updated end-lifetimes).

context.worldStateAccessTrace = nestedContext.worldStateAccessTrace;