Skip to content

Commit 8515170

Browse files
committed
feat: contractOverrides for private-call simulation
Plumbs the existing PXE-side SimulationOverrides.contracts mechanism through wallet.simulateTx and aztec.js .simulate() options as 'contractOverrides'. This covers private-function dispatch during simulation against an upgraded class - the PXE's ACIR simulator uses the override artifact for the given address instead of its registered one. Together with the existing node-side stateOverrides, .simulate() now handles both private and public call flavors when simulating against an upgraded contract. fastForwardContractUpdate now takes a ContractArtifact and returns both override layers in one blob so callers can spread it across .simulate(): const overrides = await fastForwardContractUpdate({ instanceAddress, newArtifact, node, }); await updatedContract.methods.X().simulate({ ...overrides }); Adds e2e coverage for the private-call path (set_private_value, which only exists on the upgraded class) to demonstrate the full flow.
1 parent 7e12172 commit 8515170

19 files changed

Lines changed: 227 additions & 95 deletions

File tree

docs/docs-developers/docs/aztec-js/how_to_test.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@ Test that invalid operations revert as expected:
6767

6868
Use `.simulate()` to test reverts without spending gas. The simulation will throw if the transaction would fail onchain.
6969

70-
## Simulating with state overrides
70+
## Simulating with state and contract overrides
7171

72-
`.simulate()` accepts a `stateOverrides` option that injects values into the simulator's ephemeral world-state fork before the call runs. The override is scoped to that single simulation; the real chain state is untouched.
72+
`.simulate()` accepts two override options that are scoped to that single simulation; real chain state is untouched.
73+
74+
- `stateOverrides`: state-tree overrides (e.g. `publicStorage` writes).
75+
- `contractOverrides`: an array of `ContractInstanceWithAddress` to override deployed contract instances. For private-call simulation, register the new class artifact locally first via `wallet.registerContractClass(artifact)`.
7376

7477
Override a public-storage slot:
7578

@@ -81,26 +84,37 @@ const result = await contract.methods.read_balance(account).simulate({
8184
});
8285
```
8386

84-
Use this to:
87+
Use these to:
8588

8689
- Set up state preconditions without running a full setup transaction
8790
- Reproduce a bug from production by pinning storage to the values seen at a specific block
91+
- Simulate a contract instance as if it had been upgraded
8892
- Test branches that depend on rare values without orchestrating the contract calls that produce them
8993

9094
### Fast-forwarding a contract update
9195

92-
`fastForwardContractUpdate` builds the full set of overrides needed to simulate a deployed instance as if it had already been upgraded to a new contract class. The new class must already be registered on chain. The cheat mirrors a real `pxe.updateContract` followed by waiting out the upgrade delay: the instance's `currentContractClassId` is bumped, and the `ContractInstanceRegistry`'s delayed-public-mutable storage is rewritten to look like the upgrade was scheduled in the past.
96+
`fastForwardContractUpdate` builds the override blobs needed to simulate a deployed instance as if it had already been upgraded to a new contract class. The new class must already be registered on chain. Mirrors a real `pxe.updateContract` followed by waiting out the upgrade delay.
97+
98+
It returns `stateOverrides` (registry storage rewrites) and `contractOverrides` (instance with bumped class id). A single spread covers any mix of private and public function calls on the upgraded contract.
99+
100+
For private-call simulation against the new class, register the artifact in your local PXE first via `wallet.registerContractClass(artifact)` so PXE can find its ACIR. Public-only simulations don't need this step.
93101

94102
```typescript
95103
import { fastForwardContractUpdate } from '@aztec/aztec.js';
104+
import { getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
105+
106+
// One-time, only needed if simulating private calls on the new class
107+
await wallet.registerContractClass(UpdatedContract.artifact);
96108

97-
const stateOverrides = await fastForwardContractUpdate({
109+
const newClassId = (await getContractClassFromArtifact(UpdatedContract.artifact)).id;
110+
const overrides = await fastForwardContractUpdate({
98111
instanceAddress: contract.address,
99-
newClassId: upgradedClass.id,
112+
newClassId,
100113
node,
101114
});
102115

103-
const result = await contract.methods.upgraded_method().simulate({ stateOverrides });
116+
const upgradedContract = UpdatedContract.at(contract.address, wallet);
117+
const result = await upgradedContract.methods.upgraded_method().simulate({ ...overrides });
104118
```
105119

106120
Use this to test code paths that only execute after an upgrade, without orchestrating the full delayed-mutable upgrade flow.

docs/docs-developers/docs/resources/migration_notes.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,23 @@ const result = await contract.methods.read_balance(account).simulate({
2323

2424
The same option flows through `wallet.simulateTx` and eventually to `simulatePublicCalls` RPC on `AztecNode`.
2525

26-
A second override flavor, `contractInstances`, shadows contract instances in the simulator's contract DB - useful for simulating a contract being on a different class than the one it was deployed with. To simulate a complete on-chain upgrade flow, use the `fastForwardContractUpdate` helper which produces a coherent set of `publicStorage` and `contractInstances` overrides:
26+
`.simulate(...)` also accepts `contractOverrides` - an array of `ContractInstanceWithAddress` that override deployed instances at their addresses. Applied on both the AVM (public dispatch) and PXE (private dispatch via ACIR) sides. For private dispatch to find the new class's ACIR, register the artifact locally first via `wallet.registerContractClass(artifact)` (newly exposed for this purpose).
27+
28+
To simulate a complete on-chain upgrade flow, use the `fastForwardContractUpdate` helper. It returns both `stateOverrides` (registry storage rewrites) and `contractOverrides` (instance with bumped class id). For private-call simulation, register the new artifact locally via `wallet.registerContractClass(artifact)` first so PXE can find its ACIR:
2729

2830
```typescript
29-
import { fastForwardContractUpdate } from '@aztec/aztec.js';
31+
import { fastForwardContractUpdate, getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
32+
33+
await wallet.registerContractClass(UpdatedContract.artifact); // only needed for private-call sim
3034

31-
const stateOverrides = await fastForwardContractUpdate({
35+
const newClassId = (await getContractClassFromArtifact(UpdatedContract.artifact)).id;
36+
const overrides = await fastForwardContractUpdate({
3237
instanceAddress: contract.address,
33-
newClassId: upgradedClass.id,
38+
newClassId,
3439
node,
3540
});
36-
const result = await contract.methods.upgraded_method().simulate({ stateOverrides });
41+
const upgradedContract = UpdatedContract.at(contract.address, wallet);
42+
const result = await upgradedContract.methods.upgraded_method().simulate({ ...overrides });
3743
```
3844

3945
### [PXE] `proveTx` takes an options bag

yarn-project/aztec-node/src/aztec-node/server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,7 +1484,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
14841484
* Simulates the public part of a transaction with the current state.
14851485
* @param tx - The transaction to simulate.
14861486
* @param skipFeeEnforcement - If true, fee enforcement is skipped.
1487-
* @param stateOverrides - Optional state overrides applied to the ephemeral fork and contract DB before simulation.
1487+
* @param stateOverrides - Optional state-tree overrides (publicStorage writes) applied to the ephemeral fork.
1488+
* @param contractOverrides - Optional contract instance overrides applied to AVM dispatch.
14881489
**/
14891490
@trackSpan('AztecNodeService.simulatePublicCalls', (tx: Tx) => ({
14901491
[Attributes.TX_HASH]: tx.getTxHash().toString(),
@@ -1493,6 +1494,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
14931494
tx: Tx,
14941495
skipFeeEnforcement = false,
14951496
stateOverrides?: StateOverrides,
1497+
contractOverrides?: ContractInstanceWithAddress[],
14961498
): Promise<PublicSimulationOutput> {
14971499
// Check total gas limit for simulation
14981500
const gasSettings = tx.data.constants.txContext.gasSettings;
@@ -1550,7 +1552,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
15501552
}),
15511553
});
15521554
const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings());
1553-
contractsDB.addContracts(stateOverrides?.contractInstances);
1555+
contractsDB.addContracts(contractOverrides);
15541556
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config, contractsDB);
15551557

15561558
// REFACTOR: Consider merging ProcessReturnValues into ProcessedTx

yarn-project/aztec.js/src/contract/contract.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,18 @@ describe('Contract Class', () => {
238238
from: account.getAddress(),
239239
stateOverrides: { publicStorage: [{ contract: contractAddress, slot: new Fr(1), value: new Fr(42) }] },
240240
}),
241-
).rejects.toThrow(/stateOverrides are not supported for utility/);
241+
).rejects.toThrow(/not supported for utility function/);
242+
expect(wallet.executeUtility).not.toHaveBeenCalled();
243+
});
244+
245+
it('throws when contractOverrides are passed to a utility function simulation', async () => {
246+
const fooContract = Contract.at(contractAddress, defaultArtifact, wallet);
247+
await expect(
248+
fooContract.methods.qux(123n).simulate({
249+
from: account.getAddress(),
250+
contractOverrides: [contractInstance],
251+
}),
252+
).rejects.toThrow(/not supported for utility function/);
242253
expect(wallet.executeUtility).not.toHaveBeenCalled();
243254
});
244255

yarn-project/aztec.js/src/contract/contract_function_interaction.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,8 @@ export class ContractFunctionInteraction extends BaseContractInteraction {
129129
): Promise<SimulationResult> {
130130
// docs:end:simulate
131131
if (this.functionDao.functionType == FunctionType.UTILITY) {
132-
if (
133-
options.stateOverrides &&
134-
(options.stateOverrides.publicStorage?.length || options.stateOverrides.contractInstances?.length)
135-
) {
136-
throw new Error('stateOverrides are not supported for utility function simulation.');
132+
if (options.stateOverrides?.publicStorage?.length || options.contractOverrides?.length) {
133+
throw new Error('stateOverrides and contractOverrides are not supported for utility function simulation.');
137134
}
138135
const call = await this.getFunctionCall();
139136
const scopes = [...(options.additionalScopes ?? [])];

yarn-project/aztec.js/src/contract/fastforward_contract_update.test.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,46 +32,38 @@ describe('fastForwardContractUpdate', () => {
3232
).withAddress(instanceAddress);
3333

3434
node.getContract.mockResolvedValue(instance);
35-
node.getContractClass.mockResolvedValue({
36-
id: newClassId,
37-
artifactHash: Fr.random(),
38-
packedBytecodeCommitments: [],
39-
privateFunctionsRoot: Fr.random(),
40-
publicBytecodeCommitment: Fr.random(),
41-
version: 1,
42-
privateFunctions: [],
43-
utilityFunctions: [],
44-
publicFunctions: [],
45-
packedBytecode: Buffer.alloc(0),
46-
} as any);
35+
node.getContractClass.mockResolvedValue({ id: newClassId } as any);
4736
});
4837

49-
it('produces overrides with bumped currentContractClassId and registry storage writes', async () => {
50-
const overrides = await fastForwardContractUpdate({ instanceAddress, newClassId, node });
51-
52-
expect(overrides.contractInstances).toHaveLength(1);
53-
expect(overrides.contractInstances![0].address).toEqual(instanceAddress);
54-
expect(overrides.contractInstances![0].currentContractClassId).toEqual(newClassId);
55-
expect(overrides.contractInstances![0].originalContractClassId).toEqual(originalClassId);
38+
it('produces stateOverrides with registry storage writes', async () => {
39+
const { stateOverrides } = await fastForwardContractUpdate({ instanceAddress, newClassId, node });
5640

5741
const expectedSlots = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
58-
expect(overrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
59-
for (const entry of overrides.publicStorage!) {
42+
expect(stateOverrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
43+
for (const entry of stateOverrides.publicStorage!) {
6044
expect(entry.contract).toEqual(ProtocolContractAddress.ContractInstanceRegistry);
6145
}
62-
const baseSlot = expectedSlots.delayedPublicMutableSlot;
63-
expect(overrides.publicStorage![0].slot).toEqual(baseSlot);
64-
expect(overrides.publicStorage![overrides.publicStorage!.length - 1].slot).toEqual(
46+
expect(stateOverrides.publicStorage![0].slot).toEqual(expectedSlots.delayedPublicMutableSlot);
47+
expect(stateOverrides.publicStorage![stateOverrides.publicStorage!.length - 1].slot).toEqual(
6548
expectedSlots.delayedPublicMutableHashSlot,
6649
);
6750
});
6851

52+
it('produces a contractOverrides entry with bumped currentContractClassId', async () => {
53+
const { contractOverrides } = await fastForwardContractUpdate({ instanceAddress, newClassId, node });
54+
55+
expect(contractOverrides).toHaveLength(1);
56+
expect(contractOverrides[0].address).toEqual(instanceAddress);
57+
expect(contractOverrides[0].currentContractClassId).toEqual(newClassId);
58+
expect(contractOverrides[0].originalContractClassId).toEqual(originalClassId);
59+
});
60+
6961
it('throws when the instance is not deployed', async () => {
7062
node.getContract.mockResolvedValue(undefined);
7163
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not deployed/);
7264
});
7365

74-
it('throws when the new class is not registered', async () => {
66+
it('throws when the new class is not registered on chain', async () => {
7567
node.getContractClass.mockResolvedValue(undefined);
7668
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not registered/);
7769
});

yarn-project/aztec.js/src/contract/fastforward_contract_update.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Fr } from '@aztec/foundation/curves/bn254';
22
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
33
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
4+
import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract';
45
import {
56
DelayedPublicMutableValuesWithHash,
67
ScheduledDelayChange,
@@ -9,26 +10,30 @@ import {
910
import type { AztecNode, StateOverrides } from '@aztec/stdlib/interfaces/client';
1011

1112
/**
12-
* Builds `StateOverrides` that simulate a contract instance having already been upgraded to a new contract class.
13+
* Builds the override blobs that simulate a contract instance having already been upgraded to a new contract class.
1314
*
14-
* Mirrors a real on-chain upgrade flow (`pxe.updateContract` followed by waiting out the delay): the contract
15-
* instance's `currentContractClassId` is bumped to `newClassId`, and the `ContractInstanceRegistry`'s delayed
16-
* public mutable storage is rewritten to look like the upgrade was scheduled in the past.
15+
* Mirrors a real on-chain upgrade flow (`pxe.updateContract` followed by waiting out the delay). Returns:
1716
*
18-
* The new class must already be registered on chain. To upgrade to an unregistered class, register it first
19-
* (or use a higher-level helper that bundles class registration with the upgrade).
17+
* - `stateOverrides` — node-side state-tree writes that rewrite the `ContractInstanceRegistry`'s delayed-public-mutable
18+
* storage to look like the upgrade was scheduled in the past.
19+
* - `contractOverrides` — an instance entry whose `currentContractClassId` is bumped to the new class. Applied both
20+
* AVM-side (public dispatch) and PXE-side (private dispatch).
21+
*
22+
* The new class must already be registered on chain. For private-call simulation against the new class, the artifact
23+
* must also be registered in the local PXE — call `wallet.registerContractClass(artifact)` once before invoking this
24+
* helper. Public-only simulations don't need the local artifact registration.
2025
*
2126
* @param args.instanceAddress - Address of the deployed instance to upgrade.
2227
* @param args.newClassId - ID of the (already-registered) class to upgrade to.
2328
* @param args.node - Node used to fetch the existing instance and validate the class is registered.
24-
* @returns `StateOverrides` to spread into a `simulate({ stateOverrides })` call.
25-
* @throws If the instance is not deployed, the class is not registered, or the instance is already on the target class.
29+
* @returns `{ stateOverrides, contractOverrides }` to spread into a `simulate(...)` call.
30+
* @throws If the instance is not deployed, the class is not registered on chain, or the instance is already on the target class.
2631
*/
2732
export async function fastForwardContractUpdate(args: {
2833
instanceAddress: AztecAddress;
2934
newClassId: Fr;
3035
node: AztecNode;
31-
}): Promise<StateOverrides> {
36+
}): Promise<{ stateOverrides: StateOverrides; contractOverrides: ContractInstanceWithAddress[] }> {
3237
const { instanceAddress, newClassId, node } = args;
3338

3439
const instance = await node.getContract(instanceAddress);
@@ -38,7 +43,7 @@ export async function fastForwardContractUpdate(args: {
3843

3944
const klass = await node.getContractClass(newClassId);
4045
if (!klass) {
41-
throw new Error(`Contract class ${newClassId} is not registered; register it before fast-forwarding to it`);
46+
throw new Error(`Contract class ${newClassId} is not registered on chain; publish it before fast-forwarding to it`);
4247
}
4348

4449
if (instance.currentContractClassId.equals(newClassId)) {
@@ -61,10 +66,10 @@ export async function fastForwardContractUpdate(args: {
6166
value,
6267
}));
6368

64-
const upgradedInstance = { ...instance, currentContractClassId: newClassId };
69+
const upgradedInstance: ContractInstanceWithAddress = { ...instance, currentContractClassId: newClassId };
6570

6671
return {
67-
publicStorage,
68-
contractInstances: [upgradedInstance],
72+
stateOverrides: { publicStorage },
73+
contractOverrides: [upgradedInstance],
6974
};
7075
}

yarn-project/aztec.js/src/contract/interaction_options.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Fr } from '@aztec/foundation/curves/bn254';
22
import type { FieldsOf } from '@aztec/foundation/types';
33
import type { AuthWitness } from '@aztec/stdlib/auth-witness';
44
import { AztecAddress } from '@aztec/stdlib/aztec-address';
5+
import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract';
56
import type { GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas';
67
import type { StateOverrides } from '@aztec/stdlib/interfaces/client';
78
import {
@@ -158,8 +159,15 @@ export type SimulateInteractionOptions = Omit<SendInteractionOptions, 'fee'> & {
158159
/** Whether to include metadata such as performance statistics (e.g. timing information of the different circuits and oracles) and gas estimation
159160
* in the simulation result, in addition to the return value and offchain effects */
160161
includeMetadata?: boolean;
161-
/** Pre-simulation state overrides applied to the ephemeral fork and contract DB. */
162+
/** Pre-simulation state-tree overrides (e.g. publicStorage writes) applied to the ephemeral fork. */
162163
stateOverrides?: StateOverrides;
164+
/**
165+
* Per-simulation contract instance overrides. The simulator uses each provided instance in place of the
166+
* chain-registered one at its address — applied both AVM-side (public dispatch) and PXE-side (private
167+
* dispatch). For private dispatch to find the right ACIR, register the new class first via
168+
* `wallet.registerContractClass(artifact)`.
169+
*/
170+
contractOverrides?: ContractInstanceWithAddress[];
163171
};
164172

165173
/**

yarn-project/aztec.js/src/wallet/wallet.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ describe('WalletSchema', () => {
155155
});
156156
});
157157

158+
it('registerContractClass', async () => {
159+
const mockArtifact: ContractArtifact = {
160+
name: 'TestContract',
161+
functions: [],
162+
nonDispatchPublicFunctions: [],
163+
outputs: { structs: {}, globals: {} },
164+
fileMap: {},
165+
storageLayout: {},
166+
};
167+
await context.client.registerContractClass(mockArtifact);
168+
});
169+
158170
it('simulateTx', async () => {
159171
const exec: ExecutionPayload = {
160172
calls: [],
@@ -448,6 +460,8 @@ class MockWallet implements Wallet {
448460
};
449461
}
450462

463+
async registerContractClass(_artifact: any): Promise<void> {}
464+
451465
async simulateTx(_exec: ExecutionPayload, _opts: SimulateOptions): Promise<TxSimulationResultWithAppOffset> {
452466
return TxSimulationResultWithAppOffset.fromResultAndOffset(await TxSimulationResult.random(), 0);
453467
}

0 commit comments

Comments
 (0)