Skip to content

Commit 9e17f5b

Browse files
authored
feat(pxe): backport #23007 — add execution hooks for authorizing cross-contract utility calls (#23066)
Backport of #23007 (`feat(pxe): add execution hooks for authorizing cross-contract utility calls`, by @nchamo) to `backport-to-v4-next-staging`. The auto-cherry-pick (run [25514836711](https://github.com/AztecProtocol/aztec-packages/actions/runs/25514836711)) failed with two rejected hunks. Restructured into two commits so the failure and the fix are reviewable independently: **1. `feat(pxe): add execution hooks for authorizing cross-contract utility calls (#23007)`** The cherry-pick attempt: applies the PR diff with `git apply --verbose --reject`. All clean hunks land, and the two failed hunks are recorded as `.rej` sidecar files committed alongside their target. Does not compile on its own. - `yarn-project/end-to-end/src/fixtures/setup.ts.rej` — anchor `skipInitialSequencer?: boolean;` doesn't exist on this branch. - `yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts.rej` — context shifted by an extra `log: this.logger,` line on this branch. (Target file is unmodified by this commit because its only hunk rejected.) **2. `fix: resolve cherry-pick conflicts from #23007`** Deletes the two `.rej` files and applies the equivalent edits manually: - `setup.ts` — insert `pxeCreationOptions?: PXECreationOptions;` at the end of `SetupOptions` (after `walletMinFeePadding`). - `private_execution_oracle.ts` — insert `hooks: this.hooks,` between `simulator: this.simulator,` and `l2TipsStore: this.l2TipsStore,`. `this.hooks` resolves via the `protected readonly hooks: ExecutionHooks | undefined` field that this PR adds on the parent class `UtilityExecutionOracle`. No semantic deviations from the source PR.
2 parents add1e8c + 6b38bc5 commit 9e17f5b

18 files changed

Lines changed: 306 additions & 29 deletions

File tree

docs/docs-developers/docs/aztec-nr/debugging.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,33 @@ LOG_LEVEL="silent;debug:simulator"
9999
| `No public key registered for address` | Call `wallet.registerSender(...)` |
100100
| `Direct invocation of ... functions is not supported` | Use `self.call()`, `self.view()`, or `self.enqueue()` to [call contract functions](framework-description/calling_contracts.md) |
101101
| `Failed to solve brillig function` | Check function parameters and note validity |
102+
| `Cross-contract utility call denied` | Configure an `authorizeUtilityCall` [execution hook](#cross-contract-utility-call-denied) on your PXE |
103+
104+
#### Cross-contract utility call denied
105+
106+
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:
107+
108+
```
109+
Cross-contract utility call denied: <reason>. <caller> attempted to call <target>:<selector> (<name>).
110+
```
111+
112+
To fix this, pass an `authorizeUtilityCall` hook when creating your PXE:
113+
114+
```typescript
115+
import { PXE } from "@aztec/pxe/server";
116+
117+
const pxe = await PXE.create({
118+
// ...other options
119+
hooks: {
120+
authorizeUtilityCall: async (request) => {
121+
// Inspect request.caller, request.target, request.functionSelector, etc.
122+
return { authorized: true };
123+
},
124+
},
125+
});
126+
```
127+
128+
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.
102129

103130
### Circuit Errors
104131

docs/netlify.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,3 +813,8 @@
813813
# PXE: capsule operation attempted with a scope not in the allowed scopes list
814814
from = "/errors/10"
815815
to = "/developers/docs/aztec-nr/framework-description/advanced/how_to_use_capsules"
816+
817+
[[redirects]]
818+
# PXE: cross-contract utility call denied by execution hook
819+
from = "/errors/11"
820+
to = "/developers/docs/aztec-nr/debugging#cross-contract-utility-call-denied"

noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/main.nr

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use aztec::macros::aztec;
66
#[aztec]
77
pub contract NestedUtility {
88
use aztec::macros::functions::external;
9+
use aztec::protocol::address::AztecAddress;
910

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

20+
#[external("utility")]
21+
unconstrained fn delegate_pow_utility(target: AztecAddress, x: Field, n: u32) -> Field {
22+
self.call(NestedUtility::at(target).pow_utility(x, n))
23+
}
24+
1925
#[external("private")]
2026
fn pow_private(x: Field, n: u32) -> Field {
2127
// Safety: this is a test contract; the unconstrained result is returned directly
@@ -24,4 +30,13 @@ pub contract NestedUtility {
2430
self.utility.call_self.pow_utility(x, n)
2531
}
2632
}
33+
34+
#[external("private")]
35+
fn delegate_pow_private(target: AztecAddress, x: Field, n: u32) -> Field {
36+
// Safety: this is a test contract; the unconstrained result is returned directly
37+
// and never used as input to a constrained assertion
38+
unsafe {
39+
self.utility.call(NestedUtility::at(target).pow_utility(x, n))
40+
}
41+
}
2742
}
Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AztecAddress } from '@aztec/aztec.js/addresses';
22
import type { Wallet } from '@aztec/aztec.js/wallet';
33
import { NestedUtilityContract } from '@aztec/noir-test-contracts.js/NestedUtility';
4+
import type { UtilityCallAuthorizationRequest } from '@aztec/pxe/server';
45

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

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

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

1719
let wallet: Wallet;
@@ -24,23 +26,131 @@ describe('Nested utility calls', () => {
2426
wallet,
2527
accounts: [defaultAccountAddress],
2628
} = await setup(1));
27-
({ contract } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
29+
({ contract: contractA } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
30+
({ contract: contractB } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
2831
});
2932

3033
afterAll(() => teardown());
3134

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

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

4245
it('pow_private(2, 10) returns 2^10 (private function calling utility)', async () => {
43-
const { result } = await contract.methods.pow_private(2n, 10).simulate({ from: defaultAccountAddress });
46+
const { result } = await contractA.methods.pow_private(2n, 10).simulate({ from: defaultAccountAddress });
4447
expect(result).toEqual(2n ** 10n);
4548
});
49+
50+
it('denies cross-contract utility call from utility context by default', async () => {
51+
await expect(
52+
contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
53+
).rejects.toThrow('Cross-contract utility call denied');
54+
});
55+
56+
it('denies cross-contract utility call from private function by default', async () => {
57+
await expect(
58+
contractA.methods.delegate_pow_private(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
59+
).rejects.toThrow('Cross-contract utility call denied');
60+
});
61+
});
62+
63+
describe('authorizeUtilityCall hook', () => {
64+
let contractA: NestedUtilityContract;
65+
let contractB: NestedUtilityContract;
66+
let wallet: Wallet;
67+
let defaultAccountAddress: AztecAddress;
68+
let teardown: () => Promise<void>;
69+
jest.setTimeout(TIMEOUT);
70+
71+
let hookAllows = false;
72+
let lastRequest: UtilityCallAuthorizationRequest | undefined;
73+
74+
beforeAll(async () => {
75+
({
76+
teardown,
77+
wallet,
78+
accounts: [defaultAccountAddress],
79+
} = await setup(1, {
80+
pxeCreationOptions: {
81+
hooks: {
82+
authorizeUtilityCall: (req: UtilityCallAuthorizationRequest) => {
83+
lastRequest = req;
84+
return Promise.resolve({ authorized: hookAllows });
85+
},
86+
},
87+
},
88+
}));
89+
90+
({ contract: contractA } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
91+
({ contract: contractB } = await NestedUtilityContract.deploy(wallet).send({ from: defaultAccountAddress }));
92+
});
93+
94+
afterAll(() => teardown());
95+
96+
beforeEach(() => {
97+
hookAllows = false;
98+
lastRequest = undefined;
99+
});
100+
101+
it('denies cross-contract utility call from utility context when hook returns false', async () => {
102+
await expect(
103+
contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
104+
).rejects.toThrow('Cross-contract utility call denied');
105+
expect(lastRequest).toMatchObject({
106+
caller: contractA.address,
107+
target: contractB.address,
108+
functionSelector: contractB.methods.pow_utility.selector(),
109+
functionName: 'pow_utility',
110+
callerContext: 'utility',
111+
});
112+
});
113+
114+
it('allows cross-contract utility call from utility context when hook returns true', async () => {
115+
hookAllows = true;
116+
const { result } = await contractA.methods
117+
.delegate_pow_utility(contractB.address, 2n, 3n)
118+
.simulate({ from: defaultAccountAddress });
119+
expect(result).toEqual(8n); // 2^3
120+
expect(lastRequest).toMatchObject({
121+
caller: contractA.address,
122+
target: contractB.address,
123+
functionSelector: contractB.methods.pow_utility.selector(),
124+
functionName: 'pow_utility',
125+
callerContext: 'utility',
126+
});
127+
});
128+
129+
it('denies cross-contract utility call from private function when hook returns false', async () => {
130+
await expect(
131+
contractA.methods.delegate_pow_private(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }),
132+
).rejects.toThrow('Cross-contract utility call denied');
133+
expect(lastRequest).toMatchObject({
134+
caller: contractA.address,
135+
target: contractB.address,
136+
functionSelector: contractB.methods.pow_utility.selector(),
137+
functionName: 'pow_utility',
138+
callerContext: 'private',
139+
});
140+
});
141+
142+
it('allows cross-contract utility call from private function when hook returns true', async () => {
143+
hookAllows = true;
144+
const { result } = await contractA.methods
145+
.delegate_pow_private(contractB.address, 2n, 3n)
146+
.simulate({ from: defaultAccountAddress });
147+
expect(result).toEqual(8n); // 2^3
148+
expect(lastRequest).toMatchObject({
149+
caller: contractA.address,
150+
target: contractB.address,
151+
functionSelector: contractB.methods.pow_utility.selector(),
152+
functionName: 'pow_utility',
153+
callerContext: 'private',
154+
});
155+
});
46156
});

yarn-project/end-to-end/src/fixtures/setup.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import type { P2PClientDeps } from '@aztec/p2p';
4545
import { MockGossipSubNetwork, getMockPubSubP2PServiceFactory } from '@aztec/p2p/test-helpers';
4646
import { protocolContractsHash } from '@aztec/protocol-contracts';
4747
import type { ProverNodeConfig } from '@aztec/prover-node';
48-
import { type PXEConfig, getPXEConfig } from '@aztec/pxe/server';
48+
import { type PXEConfig, type PXECreationOptions, getPXEConfig } from '@aztec/pxe/server';
4949
import type { SequencerClient } from '@aztec/sequencer-client';
5050
import { ARTIFACT_VERSION_BEFORE_INJECTION } from '@aztec/stdlib/abi';
5151
import { type ContractInstanceWithAddress, getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract';
@@ -203,6 +203,8 @@ export type SetupOptions = {
203203
l1ContractsArgs?: Partial<DeployAztecL1ContractsArgs>;
204204
/** Wallet minimum fee padding multiplier (defaults to 0.5, which is 50% padding). */
205205
walletMinFeePadding?: number;
206+
/** Options forwarded to PXE creation (e.g. execution hooks). */
207+
pxeCreationOptions?: PXECreationOptions;
206208
} & Partial<AztecNodeConfig>;
207209

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

568573
if (opts.walletMinFeePadding !== undefined) {
569574
wallet.setMinFeePadding(opts.walletMinFeePadding);

yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
} from '@aztec/stdlib/tx';
9191

9292
import type { ContractSyncService } from '../contract_sync/contract_sync_service.js';
93+
import type { ExecutionHooks } from '../hooks/index.js';
9394
import type { MessageContextService } from '../messages/message_context_service.js';
9495
import type { AddressStore } from '../storage/address_store/address_store.js';
9596
import { CapsuleService } from '../storage/capsule_store/capsule_service.js';
@@ -143,6 +144,7 @@ export type ContractFunctionSimulatorArgs = {
143144
simulator: CircuitSimulator;
144145
contractSyncService: ContractSyncService;
145146
messageContextService: MessageContextService;
147+
hooks?: ExecutionHooks;
146148
};
147149

148150
/**
@@ -164,6 +166,7 @@ export class ContractFunctionSimulator {
164166
private readonly simulator: CircuitSimulator;
165167
private readonly contractSyncService: ContractSyncService;
166168
private readonly messageContextService: MessageContextService;
169+
private readonly hooks: ExecutionHooks | undefined;
167170

168171
constructor(args: ContractFunctionSimulatorArgs) {
169172
this.contractStore = args.contractStore;
@@ -180,6 +183,7 @@ export class ContractFunctionSimulator {
180183
this.simulator = args.simulator;
181184
this.contractSyncService = args.contractSyncService;
182185
this.messageContextService = args.messageContextService;
186+
this.hooks = args.hooks;
183187
this.log = createLogger('simulator');
184188
}
185189

@@ -259,6 +263,7 @@ export class ContractFunctionSimulator {
259263
senderForTags,
260264
simulator: this.simulator,
261265
l2TipsStore: this.l2TipsStore,
266+
hooks: this.hooks,
262267
});
263268

264269
const setupTime = simulatorSetupTimer.ms();
@@ -351,6 +356,7 @@ export class ContractFunctionSimulator {
351356
jobId,
352357
scopes,
353358
simulator: this.simulator,
359+
hooks: this.hooks,
354360
});
355361

356362
try {

yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP
581581
log: this.logger,
582582
senderForTags: this.defaultSenderForTags,
583583
simulator: this.simulator,
584+
hooks: this.hooks,
584585
l2TipsStore: this.l2TipsStore,
585586
});
586587

yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -430,16 +430,6 @@ describe('Utility Execution test suite', () => {
430430
});
431431
});
432432

433-
describe('callUtilityFunction', () => {
434-
it('throws when target contract differs from execution context', async () => {
435-
const differentAddress = await AztecAddress.random();
436-
const selector = FunctionSelector.empty();
437-
await expect(utilityExecutionOracle.callUtilityFunction(differentAddress, selector, [])).rejects.toThrow(
438-
'Cross-contract utility calls are not yet supported',
439-
);
440-
});
441-
});
442-
443433
describe('invalidateContractSyncCache', () => {
444434
it('throws when contract address does not match', async () => {
445435
const otherAddress = await AztecAddress.random();

0 commit comments

Comments
 (0)