Skip to main content

Inner Workings of Functions

Below, we go more into depth of what is happening under the hood when you create a function in an Aztec contract and what the attributes are really doing.

Private functions

Aztec.nr uses an attribute system to annotate a function's type. Annotating a function with the #[aztec(private)] attribute tells the framework that this is a private function that will be executed on a users device. The compiler will create a circuit to define this function.

#aztec(private) is just syntactic sugar. At compile time, the Aztec.nr framework inserts code that allows the function to interact with the kernel.

To help illustrate how this interacts with the internals of Aztec and its kernel circuits, we can take an example private function, and explore what it looks like after Aztec.nr's macro expansion.

Before expansion

simple_macro_example
#[aztec(private)]
fn simple_macro_example(a: Field, b: Field) -> Field {
a + b
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L312-L317

After expansion

simple_macro_example_expanded
fn simple_macro_example_expanded(
// ************************************************************
// The private context inputs are made available to the circuit by the kernel
inputs: PrivateContextInputs,
// ************************************************************

// Our original inputs!
a: Field,
b: Field // The actual return type of our circuit is the PrivateCircuitPublicInputs struct, this will be the
// input to our kernel!
) -> pub PrivateCircuitPublicInputs {
// ************************************************************
// The hasher is a structure used to generate a hash of the circuits inputs.
let mut args_hasher = dep::aztec::hash::ArgsHasher::new();
args_hasher.add(a);
args_hasher.add(b);

// The context object is created with the inputs and the hash of the inputs
let mut context = PrivateContext::new(inputs, args_hasher.hash());

let mut storage = Storage::init(&mut context);
// ************************************************************

// Our actual program
let result = a + b;

// ************************************************************
// Return values are pushed into the context
let mut return_hasher = dep::aztec::hash::ArgsHasher::new();
return_hasher.add(result);
context.set_return_hash(return_hasher);

// The context is returned to be consumed by the kernel circuit!
context.finish()
// ************************************************************
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L319-L370

The expansion broken down?

Viewing the expanded Aztec contract uncovers a lot about how Aztec contracts interact with the kernel. To aid with developing intuition, we will break down each inserted line.

Receiving context from the kernel.

context-example-inputs
inputs: PrivateContextInputs,
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L323-L325

Private function calls are able to interact with each other through orchestration from within the kernel circuit. The kernel circuit forwards information to each contract function (recall each contract function is a circuit). This information then becomes part of the private context. For example, within each private function we can access some global variables. To access them we can call on the context, e.g. context.chain_id(). The value of the chain ID comes from the values passed into the circuit from the kernel.

The kernel checks that all of the values passed to each circuit in a function call are the same.

Returning the context to the kernel.

context-example-return
) -> pub PrivateCircuitPublicInputs {
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L332-L334

The contract function must return information about the execution back to the kernel. This is done through a rigid structure we call the PrivateCircuitPublicInputs.

Why is it called the PrivateCircuitPublicInputs? When verifying zk programs, return values are not computed at verification runtime, rather expected return values are provided as inputs and checked for correctness. Hence, the return values are considered public inputs.

This structure contains a host of information about the executed program. It will contain any newly created nullifiers, any messages to be sent to l2 and most importantly it will contain the return values of the function.

Hashing the function inputs.

context-example-hasher
let mut args_hasher = dep::aztec::hash::ArgsHasher::new();
args_hasher.add(a);
args_hasher.add(b);
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L337-L341

What is the hasher and why is it needed?

Inside the kernel circuits, the inputs to functions are reduced to a single value; the inputs hash. This prevents the need for multiple different kernel circuits; each supporting differing numbers of inputs. The hasher abstraction that allows us to create an array of all of the inputs that can be reduced to a single value.

Creating the function's context.

context-example-context
let mut context = PrivateContext::new(inputs, args_hasher.hash());
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L344-L346

Each Aztec function has access to a context object. This object, although labelled a global variable, is created locally on a users' device. It is initialized from the inputs provided by the kernel, and a hash of the function's inputs.

context-example-context-return
let mut return_hasher = dep::aztec::hash::ArgsHasher::new();
return_hasher.add(result);
context.set_return_hash(return_hasher);
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L358-L362

We use the kernel to pass information between circuits. This means that the return values of functions must also be passed to the kernel (where they can be later passed on to another function). We achieve this by pushing return values to the execution context, which we then pass to the kernel.

Making the contract's storage available

storage-example-context
let mut storage = Storage::init(&mut context);
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L348-L350

When a Storage struct is declared within a contract, the storage keyword is made available. As shown in the macro expansion above, this calls the init function on the storage struct with the current function's context.

Any state variables declared in the Storage struct can now be accessed as normal struct members.

Returning the function context to the kernel.

context-example-finish
context.finish()
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L365-L367

This function takes the application context, and converts it into the PrivateCircuitPublicInputs structure. This structure is then passed to the kernel circuit.

Unconstrained functions

Defining a function as unconstrained tells Aztec to simulate it completely client-side in the ACIR simulator without generating proofs. They are useful for extracting information from a user through an oracle.

When an unconstrained function is called, it prompts the ACIR simulator to

  1. generate the execution environment
  2. execute the function within this environment

To generate the environment, the simulator gets the blockheader from the PXE database and passes it along with the contract address to ViewDataOracle. This creates a context that simulates the state of the blockchain at a specific block, allowing the unconstrained function to access and interact with blockchain data as it would appear in that block, but without affecting the actual blockchain state.

Once the execution environment is created, execute_unconstrained_function is invoked:

execute_unconstrained_function
/**
* Execute an unconstrained function and return the decoded values.
*/
export async function executeUnconstrainedFunction(
oracle: ViewDataOracle,
artifact: FunctionArtifact,
contractAddress: AztecAddress,
functionSelector: FunctionSelector,
args: Fr[],
log = createDebugLogger('aztec:simulator:unconstrained_execution'),
): Promise<DecodedReturn> {
log.verbose(`Executing unconstrained function ${contractAddress}:${functionSelector}(${artifact.name})`);

const acir = artifact.bytecode;
const initialWitness = toACVMWitness(0, args);
const acirExecutionResult = await acvm(acir, initialWitness, new Oracle(oracle)).catch((err: Error) => {
throw new ExecutionError(
err.message,
{
contractAddress,
functionSelector,
},
extractCallStack(err, artifact.debug),
{ cause: err },
);
});

const returnWitness = witnessMapToFields(acirExecutionResult.returnWitness);
return decodeReturnValues(artifact.returnTypes, returnWitness);
}
Source code: yarn-project/simulator/src/client/unconstrained_execution.ts#L16-L47

This:

  1. Prepares the ACIR for execution
  2. Converts args into a format suitable for the ACVM (Abstract Circuit Virtual Machine), creating an initial witness (witness = set of inputs required to compute the function). args might be an oracle to request a user's balance
  3. Executes the function in the ACVM, which involves running the ACIR with the initial witness and the context. If requesting a user's balance, this would query the balance from the PXE database
  4. Extracts the return values from the partialWitness and decodes them based on the artifact to get the final function output. The artifact is the compiled output of the contract, and has information like the function signature, parameter types, and return types