Skip to content

Commit

Permalink
feat(avm-simulator): cap gas for external calls
Browse files Browse the repository at this point in the history
  • Loading branch information
fcarreiro committed May 16, 2024
1 parent 6417cd9 commit a1ca37c
Show file tree
Hide file tree
Showing 9 changed files with 94 additions and 60 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ const size_t ARGS_HASH_CHUNK_COUNT = 64;
const size_t MAX_ARGS_LENGTH = ARGS_HASH_CHUNK_COUNT * ARGS_HASH_CHUNK_LENGTH;
const size_t INITIAL_L2_BLOCK_NUM = 1;
const size_t BLOB_SIZE_IN_BYTES = 31 * 4096;
const size_t NESTED_CALL_L2_GAS_BUFFER = 20000;
const size_t MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS = 20000;
const size_t MAX_PACKED_BYTECODE_SIZE_PER_PRIVATE_FUNCTION_IN_FIELDS = 3000;
const size_t MAX_PACKED_BYTECODE_SIZE_PER_UNCONSTRAINED_FUNCTION_IN_FIELDS = 3000;
Expand Down
55 changes: 35 additions & 20 deletions docs/docs/protocol-specs/public-vm/nested-calls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,41 @@
A **nested contract call** occurs _during_ AVM execution and is triggered by a **contract call instruction**. The AVM [instruction set](./instruction-set) includes three contract call instructions: [`CALL`](./instruction-set#isa-section-call), [`STATICCALL`](./instruction-set#isa-section-staticcall), and [`DELEGATECALL`](./instruction-set#isa-section-delegatecall).

A nested contract call performs the following operations:

1. [Charge gas](#gas-cost-of-call-instruction) for the nested call
1. [Trace the nested contract call](#tracing-nested-contract-calls)
1. [Derive the **nested context**](#context-initialization-for-nested-calls) from the calling context and the call instruction
1. Initiate [AVM execution](./execution) within the nested context until a halt is reached
1. [Update the **calling context**](#updating-the-calling-context-after-nested-call-halts) after the nested call halts

Or, in pseudocode:

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

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

chargeGas(context,
l2GasCost=M[instr.args.gasOffset],
daGasCost=M[instr.args.gasOffset+1])
traceNestedCall(context, instr.args.addrOffset)
nestedContext = deriveContext(context, instr.args, isStaticCall, isDelegateCall)
execute(nestedContext)
updateContextAfterNestedCall(context, instr.args, nestedContext)
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](./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.

```jsx
traceNestedCall(context, addrOffset)
// which is shorthand for
Expand All @@ -50,21 +58,20 @@ import NestedContext from "./_nested-context.md";

<NestedContext />


## 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.

```jsx
chargeGas(context,
l2GasCost=M[gasOffset],
daGasCost=M[gasOffset+1])
chargeGas(context, l2GasCost, daGasCost);
```

> The shorthand `chargeGas` is defined in ["Gas checks and tracking"](./execution#gas-checks-and-tracking).
As with all instructions, gas is checked and cost is deducted _prior_ to the instruction's execution.

```jsx
assert context.machineState.l2GasLeft - l2GasCost >= 0
assert context.machineState.daGasLeft - daGasCost >= 0
Expand All @@ -77,8 +84,9 @@ When the nested call halts, it may not have used up its entire gas allocation. A
## Nested execution

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

```jsx
execute(nestedContext)
execute(nestedContext);
```

Execution (and the `execution` shorthand above) is detailed in ["Execution, Gas, Halting"](./execution). Note that execution mutates the nested context.
Expand All @@ -88,27 +96,32 @@ Execution (and the `execution` shorthand above) is detailed in ["Execution, Gas,
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"](./instruction-set):

```jsx
updateContextAfterNestedCall(context, instr.args, nestedContext)
updateContextAfterNestedCall(context, instr.args, nestedContext);
```

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

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

Any unused gas is refunded to the caller.

```jsx
context.l2GasLeft += nestedContext.machineState.l2GasLeft
context.daGasLeft += nestedContext.machineState.daGasLeft
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.

```jsx
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.

```jsx
if !nestedContext.results.reverted:
context.worldState = nestedContext.worldState
Expand All @@ -118,6 +131,7 @@ if !nestedContext.results.reverted:
### 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.

```jsx
if nestedContext.results.reverted:
// process all traces (this is shorthand)
Expand All @@ -132,6 +146,7 @@ if nestedContext.results.reverted:
> 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).

```jsx
context.worldStateAccessTrace = nestedContext.worldStateAccessTrace
context.worldStateAccessTrace = nestedContext.worldStateAccessTrace;
```
1 change: 0 additions & 1 deletion l1-contracts/src/core/libraries/ConstantsGen.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ library Constants {
uint256 internal constant INITIALIZATION_SLOT_SEPARATOR = 1000_000_000;
uint256 internal constant INITIAL_L2_BLOCK_NUM = 1;
uint256 internal constant BLOB_SIZE_IN_BYTES = 31 * 4096;
uint256 internal constant NESTED_CALL_L2_GAS_BUFFER = 20000;
uint256 internal constant MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS = 20000;
uint256 internal constant MAX_PACKED_BYTECODE_SIZE_PER_PRIVATE_FUNCTION_IN_FIELDS = 3000;
uint256 internal constant MAX_PACKED_BYTECODE_SIZE_PER_UNCONSTRAINED_FUNCTION_IN_FIELDS = 3000;
Expand Down
12 changes: 6 additions & 6 deletions noir-projects/aztec-nr/aztec/src/context/avm_context.nr
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
use crate::hash::{compute_secret_hash, compute_message_hash, compute_message_nullifier};
use dep::protocol_types::{
address::{AztecAddress, EthAddress},
constants::{L1_TO_L2_MESSAGE_LENGTH, NESTED_CALL_L2_GAS_BUFFER}, header::Header
};
use dep::protocol_types::{address::{AztecAddress, EthAddress}, constants::L1_TO_L2_MESSAGE_LENGTH, header::Header};
use dep::protocol_types::traits::{Deserialize, Serialize, Empty};
use dep::protocol_types::abis::function_selector::FunctionSelector;
use dep::protocol_types::abis::public_circuit_public_inputs::PublicCircuitPublicInputs;
Expand Down Expand Up @@ -200,9 +197,12 @@ impl Empty for AvmContext {

// Helper functions
fn gas_for_call(user_gas: GasOpts) -> [Field; 2] {
// It's ok to use the max possible gas here, because the gas will be
// capped by the gas left in the (STATIC)CALL instruction.
let MAX_POSSIBLE_FIELD: Field = 0 - 1;
[
user_gas.l2_gas.unwrap_or_else(|| l2_gas_left() - NESTED_CALL_L2_GAS_BUFFER),
user_gas.da_gas.unwrap_or_else(|| da_gas_left())
user_gas.l2_gas.unwrap_or(MAX_POSSIBLE_FIELD),
user_gas.da_gas.unwrap_or(MAX_POSSIBLE_FIELD)
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ global MAX_ARGS_LENGTH: u64 = ARGS_HASH_CHUNK_COUNT * ARGS_HASH_CHUNK_LENGTH;
global INITIALIZATION_SLOT_SEPARATOR: Field = 1000_000_000;
global INITIAL_L2_BLOCK_NUM: Field = 1;
global BLOB_SIZE_IN_BYTES: Field = 31 * 4096;
// How much gas is subtracted from L2GASLEFT when making a nested public call by default in the AVM
global NESTED_CALL_L2_GAS_BUFFER = 20000;

// CONTRACT CLASS CONSTANTS
global MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS: u64 = 20000;
Expand Down
1 change: 0 additions & 1 deletion yarn-project/circuits.js/src/constants.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export const MAX_ARGS_LENGTH = ARGS_HASH_CHUNK_COUNT * ARGS_HASH_CHUNK_LENGTH;
export const INITIALIZATION_SLOT_SEPARATOR = 1000_000_000;
export const INITIAL_L2_BLOCK_NUM = 1;
export const BLOB_SIZE_IN_BYTES = 31 * 4096;
export const NESTED_CALL_L2_GAS_BUFFER = 20000;
export const MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS = 20000;
export const MAX_PACKED_BYTECODE_SIZE_PER_PRIVATE_FUNCTION_IN_FIELDS = 3000;
export const MAX_PACKED_BYTECODE_SIZE_PER_UNCONSTRAINED_FUNCTION_IN_FIELDS = 3000;
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/simulator/src/avm/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ export function initGlobalVariables(overrides?: Partial<GlobalVariables>): Globa
*/
export function initMachineState(overrides?: Partial<AvmMachineState>): AvmMachineState {
return AvmMachineState.fromState({
l2GasLeft: overrides?.l2GasLeft ?? 100e6,
daGasLeft: overrides?.daGasLeft ?? 100e6,
l2GasLeft: overrides?.l2GasLeft ?? 1e8,
daGasLeft: overrides?.daGasLeft ?? 1e8,
});
}

Expand Down
56 changes: 34 additions & 22 deletions yarn-project/simulator/src/avm/opcodes/external_calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { adjustCalldataIndex, initContext } from '../fixtures/index.js';
import { HostStorage } from '../journal/host_storage.js';
import { AvmPersistableStateManager } from '../journal/journal.js';
import { encodeToBytecode } from '../serialization/bytecode_serialization.js';
import { L2GasLeft } from './context_getters.js';
import { Call, Return, Revert, StaticCall } from './external_calls.js';
import { type Instruction } from './instruction.js';
import { CalldataCopy } from './memory.js';
Expand Down Expand Up @@ -40,7 +41,7 @@ describe('External Calls', () => {
...Buffer.from('d2345678', 'hex'), // retOffset
...Buffer.from('e2345678', 'hex'), // retSize
...Buffer.from('f2345678', 'hex'), // successOffset
...Buffer.from('f3345678', 'hex'), // temporaryFunctionSelectorOffset
...Buffer.from('f3345678', 'hex'), // functionSelectorOffset
]);
const inst = new Call(
/*indirect=*/ 0x01,
Expand All @@ -51,7 +52,7 @@ describe('External Calls', () => {
/*retOffset=*/ 0xd2345678,
/*retSize=*/ 0xe2345678,
/*successOffset=*/ 0xf2345678,
/*temporaryFunctionSelectorOffset=*/ 0xf3345678,
/*functionSelectorOffset=*/ 0xf3345678,
);

expect(Call.deserialize(buf)).toEqual(inst);
Expand Down Expand Up @@ -86,8 +87,7 @@ describe('External Calls', () => {
]),
);

// const { l2GasLeft: initialL2Gas, daGasLeft: initialDaGas } = context.machineState;
const { daGasLeft: initialDaGas } = context.machineState;
const { l2GasLeft: initialL2Gas, daGasLeft: initialDaGas } = context.machineState;

context.machineState.memory.set(0, new Field(l2Gas));
context.machineState.memory.set(1, new Field(daGas));
Expand All @@ -107,7 +107,7 @@ describe('External Calls', () => {
retOffset,
retSize,
successOffset,
/*temporaryFunctionSelectorOffset=*/ 0,
/*functionSelectorOffset=*/ 0,
);
await instruction.execute(context);

Expand All @@ -128,48 +128,60 @@ describe('External Calls', () => {
const expectedStoredValue = new Fr(1n);
expect(nestedContractWrites!.get(slotNumber)).toEqual(expectedStoredValue);

// TODO(https://github.com/AztecProtocol/aztec-packages/issues/5625): gas not plumbed through correctly in nested calls.
// expect(context.machineState.l2GasLeft).toEqual(initialL2Gas - otherContextInstructionsL2GasCost);
expect(context.machineState.l2GasLeft).toBeLessThan(initialL2Gas);
expect(context.machineState.daGasLeft).toEqual(initialDaGas);
});

it('Should refuse to execute a call if not enough gas', async () => {
it('Should cap to available gas if allocated is bigger', async () => {
const gasOffset = 0;
const l2Gas = 1e9;
const daGas = 3e6;
const daGas = 1e9;
const addrOffset = 2;
const addr = new Fr(123456n);
const argsOffset = 3;
const args = [new Field(1n), new Field(2n), new Field(3n)];
const argsSize = args.length;
const argsSize = 0;
const argsSizeOffset = 20;
const retOffset = 7;
const retSize = 2;
const retSize = 1;
const successOffset = 6;

const otherContextInstructionsBytecode = markBytecodeAsAvm(
encodeToBytecode([
new L2GasLeft(/*indirect=*/ 0, /*dstOffset=*/ 0),
new Return(/*indirect=*/ 0, /*retOffset=*/ 0, /*size=*/ 1),
]),
);

const { l2GasLeft: initialL2Gas, daGasLeft: initialDaGas } = context.machineState;

context.machineState.memory.set(0, new Field(l2Gas));
context.machineState.memory.set(1, new Field(daGas));
context.machineState.memory.set(2, new Field(addr));
context.machineState.memory.set(argsSizeOffset, new Uint32(argsSize));
context.machineState.memory.setSlice(3, args);

jest
.spyOn(context.persistableState.hostStorage.contractsDb, 'getBytecode')
.mockRejectedValue(new Error('No bytecode expected to be requested since not enough gas'));
.mockReturnValue(Promise.resolve(otherContextInstructionsBytecode));

const instruction = new Call(
/*indirect=*/ 0,
gasOffset,
addrOffset,
argsOffset,
/*argsOffset=*/ 0,
argsSizeOffset,
retOffset,
retSize,
successOffset,
/*temporaryFunctionSelectorOffset=*/ 0,
/*functionSelectorOffset=*/ 0,
);
await instruction.execute(context);

await expect(() => instruction.execute(context)).rejects.toThrow(/Not enough.*gas left/i);
const successValue = context.machineState.memory.get(successOffset);
expect(successValue).toEqual(new Uint8(1n));

const retValue = context.machineState.memory.get(retOffset).toBigInt();
expect(retValue).toBeLessThan(initialL2Gas);

expect(context.machineState.l2GasLeft).toBeLessThan(initialL2Gas);
expect(context.machineState.daGasLeft).toEqual(initialDaGas);
});
});

Expand All @@ -185,7 +197,7 @@ describe('External Calls', () => {
...Buffer.from('d2345678', 'hex'), // retOffset
...Buffer.from('e2345678', 'hex'), // retSize
...Buffer.from('f2345678', 'hex'), // successOffset
...Buffer.from('f3345678', 'hex'), // temporaryFunctionSelectorOffset
...Buffer.from('f3345678', 'hex'), // functionSelectorOffset
]);
const inst = new StaticCall(
/*indirect=*/ 0x01,
Expand All @@ -196,7 +208,7 @@ describe('External Calls', () => {
/*retOffset=*/ 0xd2345678,
/*retSize=*/ 0xe2345678,
/*successOffset=*/ 0xf2345678,
/*temporaryFunctionSelectorOffset=*/ 0xf3345678,
/*functionSelectorOffset=*/ 0xf3345678,
);

expect(StaticCall.deserialize(buf)).toEqual(inst);
Expand Down Expand Up @@ -241,7 +253,7 @@ describe('External Calls', () => {
retOffset,
retSize,
successOffset,
/*temporaryFunctionSelectorOffset=*/ 0,
/*functionSelectorOffset=*/ 0,
);
await expect(() => instruction.execute(context)).rejects.toThrow(
'Static call cannot update the state, emit L2->L1 messages or generate logs',
Expand Down
22 changes: 17 additions & 5 deletions yarn-project/simulator/src/avm/opcodes/external_calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,23 @@ abstract class ExternalCall extends Instruction {
const callAddress = memory.getAs<Field>(addrOffset);
const calldataSize = memory.get(argsSizeOffset).toNumber();
const calldata = memory.getSlice(argsOffset, calldataSize).map(f => f.toFr());
const l2Gas = memory.get(gasOffset).toNumber();
const daGas = memory.getAs<Field>(gasOffset + 1).toNumber();
const functionSelector = memory.getAs<Field>(this.functionSelectorOffset).toFr();
// If we are already in a static call, we propagate the environment.
const callType = context.environment.isStaticCall ? 'STATICCALL' : this.type;

const allocatedGas = { l2Gas, daGas };
// First we consume the gas for this operation.
const memoryOperations = { reads: calldataSize + 5, writes: 1 + this.retSize, indirect: this.indirect };
const totalGas = sumGas(this.gasCost(memoryOperations), allocatedGas);
context.machineState.consumeGas(totalGas);
context.machineState.consumeGas(this.gasCost(memoryOperations));
// Then we consume the gas allocated for the nested call. The excess will be refunded later.
// Gas allocation is capped by the amount of gas left in the current context.
// We have to do some dancing here because the gas allocation is a field,
// but in the machine state we track gas as a number.
const allocatedL2Gas = Number(BigIntMin(memory.get(gasOffset).toBigInt(), BigInt(context.machineState.l2GasLeft)));
const allocatedDaGas = Number(
BigIntMin(memory.get(gasOffset + 1).toBigInt(), BigInt(context.machineState.daGasLeft)),
);
const allocatedGas = { l2Gas: allocatedL2Gas, daGas: allocatedDaGas };
context.machineState.consumeGas(allocatedGas);

// TRANSITIONAL: This should be removed once the kernel handles and entire enqueued call per circuit
const nestedContext = context.createNestedContractCallContext(
Expand Down Expand Up @@ -217,3 +224,8 @@ export class Revert extends Instruction {
memory.assert(memoryOperations);
}
}

/** Returns the smaller of two bigints. */
function BigIntMin(a: bigint, b: bigint): bigint {
return a < b ? a : b;
}

0 comments on commit a1ca37c

Please sign in to comment.