Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/docs-developers/docs/aztec-nr/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,33 @@ LOG_LEVEL="silent;debug:simulator"
| `No public key registered for address` | Call `wallet.registerSender(...)` |
| `Direct invocation of ... functions is not supported` | Use `self.call()`, `self.view()`, or `self.enqueue()` to [call contract functions](framework-description/calling_contracts.md) |
| `Failed to solve brillig function` | Check function parameters and note validity |
| `Cross-contract utility call denied` | Configure an `authorizeUtilityCall` [execution hook](#cross-contract-utility-call-denied) on your PXE |

#### Cross-contract utility call denied

When a contract executes a utility function that calls into a different contract, PXE asks an **execution hook** whether the call should be allowed. If no hook is configured, or the hook denies the request, you will see:

```
Cross-contract utility call denied: <reason>. <caller> attempted to call <target>:<selector> (<name>).
```

To fix this, pass an `authorizeUtilityCall` hook when creating your PXE:

```typescript
import { PXE } from "@aztec/pxe/server";

const pxe = await PXE.create({
// ...other options
hooks: {
authorizeUtilityCall: async (request) => {
// Inspect request.caller, request.target, request.functionSelector, etc.
return { authorized: true };
},
},
});
```

The hook receives a `UtilityCallAuthorizationRequest` with the caller address, target address, function selector, function name, arguments, and caller context (`'private'` or `'utility'`). Return `{ authorized: true }` to allow or `{ authorized: false, reason: '...' }` to deny with a message.

### Circuit Errors

Expand Down
5 changes: 5 additions & 0 deletions docs/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -813,3 +813,8 @@
# PXE: capsule operation attempted with a scope not in the allowed scopes list
from = "/errors/10"
to = "/developers/docs/aztec-nr/framework-description/advanced/how_to_use_capsules"

[[redirects]]
# PXE: cross-contract utility call denied by execution hook
from = "/errors/11"
to = "/developers/docs/aztec-nr/debugging#cross-contract-utility-call-denied"
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use aztec::macros::aztec;
#[aztec]
pub contract NestedUtility {
use aztec::macros::functions::external;
use aztec::protocol::address::AztecAddress;

#[external("utility")]
unconstrained fn pow_utility(x: Field, n: u32) -> Field {
Expand All @@ -16,6 +17,11 @@ pub contract NestedUtility {
}
}

#[external("utility")]
unconstrained fn delegate_pow_utility(target: AztecAddress, x: Field, n: u32) -> Field {
self.call(NestedUtility::at(target).pow_utility(x, n))
}

#[external("private")]
fn pow_private(x: Field, n: u32) -> Field {
// Safety: this is a test contract; the unconstrained result is returned directly
Expand All @@ -24,4 +30,13 @@ pub contract NestedUtility {
self.utility.call_self.pow_utility(x, n)
}
}

#[external("private")]
fn delegate_pow_private(target: AztecAddress, x: Field, n: u32) -> Field {
// Safety: this is a test contract; the unconstrained result is returned directly
// and never used as input to a constrained assertion
unsafe {
self.utility.call(NestedUtility::at(target).pow_utility(x, n))
}
}
}
122 changes: 116 additions & 6 deletions yarn-project/end-to-end/src/e2e_nested_utility_calls.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AztecAddress } from '@aztec/aztec.js/addresses';
import type { Wallet } from '@aztec/aztec.js/wallet';
import { NestedUtilityContract } from '@aztec/noir-test-contracts.js/NestedUtility';
import type { UtilityCallAuthorizationRequest } from '@aztec/pxe/server';

import { jest } from '@jest/globals';

Expand All @@ -9,9 +10,10 @@ import { setup } from './fixtures/utils.js';
const TIMEOUT = 120_000;

// Verifies nested utility calls via pow_utility(x, n) = x^n (recursive utility→utility),
// and calling it from a private function via pow_private.
// calling it from a private function via pow_private, and the default hook behavior.
describe('Nested utility calls', () => {
let contract: NestedUtilityContract;
let contractA: NestedUtilityContract;
let contractB: NestedUtilityContract;
jest.setTimeout(TIMEOUT);

let wallet: Wallet;
Expand All @@ -24,23 +26,131 @@ describe('Nested utility calls', () => {
wallet,
accounts: [defaultAccountAddress],
} = await setup(1));
({ contract } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
({ contract: contractA } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
({ contract: contractB } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
});

afterAll(() => teardown());

it('pow_utility(x, 0) returns 1 (base case, no nested call)', async () => {
const { result } = await contract.methods.pow_utility(2n, 0).simulate({ from: defaultAccountAddress });
const { result } = await contractA.methods.pow_utility(2n, 0).simulate({ from: defaultAccountAddress });
expect(result).toEqual(1n);
});

it('pow_utility(2, 10) returns 2^10 (10 levels of nesting)', async () => {
const { result } = await contract.methods.pow_utility(2n, 10).simulate({ from: defaultAccountAddress });
const { result } = await contractA.methods.pow_utility(2n, 10).simulate({ from: defaultAccountAddress });
expect(result).toEqual(2n ** 10n);
});

it('pow_private(2, 10) returns 2^10 (private function calling utility)', async () => {
const { result } = await contract.methods.pow_private(2n, 10).simulate({ from: defaultAccountAddress });
const { result } = await contractA.methods.pow_private(2n, 10).simulate({ from: defaultAccountAddress });
expect(result).toEqual(2n ** 10n);
});

it('denies cross-contract utility call from utility context by default', async () => {
await expect(
contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
).rejects.toThrow('Cross-contract utility call denied');
});

it('denies cross-contract utility call from private function by default', async () => {
await expect(
contractA.methods.delegate_pow_private(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
).rejects.toThrow('Cross-contract utility call denied');
});
});

describe('authorizeUtilityCall hook', () => {
let contractA: NestedUtilityContract;
let contractB: NestedUtilityContract;
let wallet: Wallet;
let defaultAccountAddress: AztecAddress;
let teardown: () => Promise<void>;
jest.setTimeout(TIMEOUT);

let hookAllows = false;
let lastRequest: UtilityCallAuthorizationRequest | undefined;

beforeAll(async () => {
({
teardown,
wallet,
accounts: [defaultAccountAddress],
} = await setup(1, {
pxeCreationOptions: {
hooks: {
authorizeUtilityCall: (req: UtilityCallAuthorizationRequest) => {
lastRequest = req;
return Promise.resolve({ authorized: hookAllows });
},
},
},
}));

({ contract: contractA } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
({ contract: contractB } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
});

afterAll(() => teardown());

beforeEach(() => {
hookAllows = false;
lastRequest = undefined;
});

it('denies cross-contract utility call from utility context when hook returns false', async () => {
await expect(
contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
).rejects.toThrow('Cross-contract utility call denied');
expect(lastRequest).toMatchObject({
caller: contractA.address,
target: contractB.address,
functionSelector: contractB.methods.pow_utility.selector(),
functionName: 'pow_utility',
callerContext: 'utility',
});
});

it('allows cross-contract utility call from utility context when hook returns true', async () => {
hookAllows = true;
const { result } = await contractA.methods
.delegate_pow_utility(contractB.address, 2n, 3n)
.simulate({ from: defaultAccountAddress });
expect(result).toEqual(8n); // 2^3
expect(lastRequest).toMatchObject({
caller: contractA.address,
target: contractB.address,
functionSelector: contractB.methods.pow_utility.selector(),
functionName: 'pow_utility',
callerContext: 'utility',
});
});

it('denies cross-contract utility call from private function when hook returns false', async () => {
await expect(
contractA.methods.delegate_pow_private(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
).rejects.toThrow('Cross-contract utility call denied');
expect(lastRequest).toMatchObject({
caller: contractA.address,
target: contractB.address,
functionSelector: contractB.methods.pow_utility.selector(),
functionName: 'pow_utility',
callerContext: 'private',
});
});

it('allows cross-contract utility call from private function when hook returns true', async () => {
hookAllows = true;
const { result } = await contractA.methods
.delegate_pow_private(contractB.address, 2n, 3n)
.simulate({ from: defaultAccountAddress });
expect(result).toEqual(8n); // 2^3
expect(lastRequest).toMatchObject({
caller: contractA.address,
target: contractB.address,
functionSelector: contractB.methods.pow_utility.selector(),
functionName: 'pow_utility',
callerContext: 'private',
});
});
});
9 changes: 7 additions & 2 deletions yarn-project/end-to-end/src/fixtures/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import type { P2PClientDeps } from '@aztec/p2p';
import { MockGossipSubNetwork, getMockPubSubP2PServiceFactory } from '@aztec/p2p/test-helpers';
import { protocolContractsHash } from '@aztec/protocol-contracts';
import type { ProverNodeConfig } from '@aztec/prover-node';
import { type PXEConfig, getPXEConfig } from '@aztec/pxe/server';
import { type PXEConfig, type PXECreationOptions, getPXEConfig } from '@aztec/pxe/server';
import type { SequencerClient } from '@aztec/sequencer-client';
import { ARTIFACT_VERSION_BEFORE_INJECTION } from '@aztec/stdlib/abi';
import { type ContractInstanceWithAddress, getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract';
Expand Down Expand Up @@ -203,6 +203,8 @@ export type SetupOptions = {
l1ContractsArgs?: Partial<DeployAztecL1ContractsArgs>;
/** Wallet minimum fee padding multiplier (defaults to 0.5, which is 50% padding). */
walletMinFeePadding?: number;
/** Options forwarded to PXE creation (e.g. execution hooks). */
pxeCreationOptions?: PXECreationOptions;
} & Partial<AztecNodeConfig>;

/** Context for an end-to-end test as returned by the `setup` function */
Expand Down Expand Up @@ -563,7 +565,10 @@ export async function setup(
pxeConfig.dataDirectory = path.join(directoryToCleanup, randomBytes(8).toString('hex'));
// For tests we only want proving enabled if specifically requested
pxeConfig.proverEnabled = !!pxeOpts.proverEnabled;
const wallet = await TestWallet.create(aztecNodeService, pxeConfig, { loggerActorLabel: 'pxe-0' });
const wallet = await TestWallet.create(aztecNodeService, pxeConfig, {
loggerActorLabel: 'pxe-0',
...opts.pxeCreationOptions,
});

if (opts.walletMinFeePadding !== undefined) {
wallet.setMinFeePadding(opts.walletMinFeePadding);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
} from '@aztec/stdlib/tx';

import type { ContractSyncService } from '../contract_sync/contract_sync_service.js';
import type { ExecutionHooks } from '../hooks/index.js';
import type { MessageContextService } from '../messages/message_context_service.js';
import type { AddressStore } from '../storage/address_store/address_store.js';
import { CapsuleService } from '../storage/capsule_store/capsule_service.js';
Expand Down Expand Up @@ -143,6 +144,7 @@ export type ContractFunctionSimulatorArgs = {
simulator: CircuitSimulator;
contractSyncService: ContractSyncService;
messageContextService: MessageContextService;
hooks?: ExecutionHooks;
};

/**
Expand All @@ -164,6 +166,7 @@ export class ContractFunctionSimulator {
private readonly simulator: CircuitSimulator;
private readonly contractSyncService: ContractSyncService;
private readonly messageContextService: MessageContextService;
private readonly hooks: ExecutionHooks | undefined;

constructor(args: ContractFunctionSimulatorArgs) {
this.contractStore = args.contractStore;
Expand All @@ -180,6 +183,7 @@ export class ContractFunctionSimulator {
this.simulator = args.simulator;
this.contractSyncService = args.contractSyncService;
this.messageContextService = args.messageContextService;
this.hooks = args.hooks;
this.log = createLogger('simulator');
}

Expand Down Expand Up @@ -259,6 +263,7 @@ export class ContractFunctionSimulator {
senderForTags,
simulator: this.simulator,
l2TipsStore: this.l2TipsStore,
hooks: this.hooks,
});

const setupTime = simulatorSetupTimer.ms();
Expand Down Expand Up @@ -351,6 +356,7 @@ export class ContractFunctionSimulator {
jobId,
scopes,
simulator: this.simulator,
hooks: this.hooks,
});

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP
log: this.logger,
senderForTags: this.defaultSenderForTags,
simulator: this.simulator,
hooks: this.hooks,
l2TipsStore: this.l2TipsStore,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,16 +430,6 @@ describe('Utility Execution test suite', () => {
});
});

describe('callUtilityFunction', () => {
it('throws when target contract differs from execution context', async () => {
const differentAddress = await AztecAddress.random();
const selector = FunctionSelector.empty();
await expect(utilityExecutionOracle.callUtilityFunction(differentAddress, selector, [])).rejects.toThrow(
'Cross-contract utility calls are not yet supported',
);
});
});

describe('invalidateContractSyncCache', () => {
it('throws when contract address does not match', async () => {
const otherAddress = await AztecAddress.random();
Expand Down
Loading
Loading