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:
- Charge gas for the nested call
- Trace the nested contract call
- Derive the nested context from the calling context and the call instruction
- Initiate AVM execution within the nested context until a halt is reached
- 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
chargeGas(context,
l1GasCost=M[instr.args.gasOffset],
l2GasCost=M[instr.args.gasOffset+1],
daGasCost=M[instr.args.gasOffset+2])
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 {
origin: context.origin,
sender: isDelegateCall ? context.sender : context.address,
address: M[addrOffset],
storageAddress: isDelegateCall ? context.storageAddress : M[addrOffset],
portal: callingContext.worldState.contracts[M[addrOffset]].portal,
feePerL1Gas: context.environment.feePerL1Gas,
feePerL2Gas: context.environment.feePerL2Gas,
feePerDaGas: context.environment.feePerDaGas,
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 {
l1GasLeft: context.machineState.memory[M[gasOffset]],
l2GasLeft: context.machineState.memory[M[gasOffset+1]],
daGasLeft: context.machineState.memory[M[gasOffset+2]],
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 forcontext.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,
l1GasCost=M[gasOffset],
l2GasCost=M[gasOffset+1],
daGasCost=M[gasOffset+2])
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.l1GasLeft - l1GasCost >= 0
assert context.machineState.l2GasLeft - l2GasCost >= 0
assert context.machineState.daGasLeft - daGasCost >= 0
context.l1GasLeft -= l1GasCost
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.l1GasLeft += nestedContext.machineState.l1GasLeft
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