Skip to content

Commit 5afa477

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 5afa477

19 files changed

Lines changed: 226 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. 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+
Register the new class artifact in your local PXE first via `wallet.registerContractClass(artifact)`.
93101

94102
```typescript
95103
import { fastForwardContractUpdate } from '@aztec/aztec.js';
104+
import { getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
105+
106+
// One-time local PXE registration of the new class artifact
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. Register the new class artifact locally first via `wallet.registerContractClass(artifact)` (newly exposed for this purpose) so PXE has the ACIR.
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). Register the new artifact locally via `wallet.registerContractClass(artifact)` first:
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);
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: 17 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,29 @@ 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. The artifact must also be registered in the local PXE — call
23+
* `wallet.registerContractClass(artifact)` once before invoking this helper.
2024
*
2125
* @param args.instanceAddress - Address of the deployed instance to upgrade.
2226
* @param args.newClassId - ID of the (already-registered) class to upgrade to.
2327
* @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.
28+
* @returns `{ stateOverrides, contractOverrides }` to spread into a `simulate(...)` call.
29+
* @throws If the instance is not deployed, the class is not registered on chain, or the instance is already on the target class.
2630
*/
2731
export async function fastForwardContractUpdate(args: {
2832
instanceAddress: AztecAddress;
2933
newClassId: Fr;
3034
node: AztecNode;
31-
}): Promise<StateOverrides> {
35+
}): Promise<{ stateOverrides: StateOverrides; contractOverrides: ContractInstanceWithAddress[] }> {
3236
const { instanceAddress, newClassId, node } = args;
3337

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

3943
const klass = await node.getContractClass(newClassId);
4044
if (!klass) {
41-
throw new Error(`Contract class ${newClassId} is not registered; register it before fast-forwarding to it`);
45+
throw new Error(`Contract class ${newClassId} is not registered on chain; publish it before fast-forwarding to it`);
4246
}
4347

4448
if (instance.currentContractClassId.equals(newClassId)) {
@@ -61,10 +65,10 @@ export async function fastForwardContractUpdate(args: {
6165
value,
6266
}));
6367

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

6670
return {
67-
publicStorage,
68-
contractInstances: [upgradedInstance],
71+
stateOverrides: { publicStorage },
72+
contractOverrides: [upgradedInstance],
6973
};
7074
}

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
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ export type Wallet = {
272272
artifact?: ContractArtifact,
273273
secretKey?: Fr,
274274
): Promise<ContractInstanceWithAddress>;
275+
/**
276+
* Registers a contract class artifact in the local PXE without binding it to any instance.
277+
* Useful for simulation flows that need the artifact available locally before any on-chain
278+
* upgrade has taken effect. The artifact's class id must match its content; no chain check.
279+
*/
280+
registerContractClass(artifact: ContractArtifact): Promise<void>;
275281
simulateTx(exec: ExecutionPayload, opts: SimulateOptions): Promise<TxSimulationResultWithAppOffset>;
276282
executeUtility(call: FunctionCall, opts: ExecuteUtilityOptions): Promise<UtilityExecutionResult>;
277283
profileTx(exec: ExecutionPayload, opts: ProfileOptions): Promise<TxProfileResult>;
@@ -337,6 +343,7 @@ export const SimulateOptionsSchema = z.object({
337343
includeMetadata: optional(z.boolean()),
338344
additionalScopes: optional(z.array(schemas.AztecAddress)),
339345
stateOverrides: optional(StateOverridesSchema),
346+
contractOverrides: optional(z.array(ContractInstanceWithAddressSchema)),
340347
});
341348

342349
export const ProfileOptionsSchema = SimulateOptionsSchema.extend({
@@ -559,6 +566,7 @@ const WalletMethodSchemas = {
559566
.function()
560567
.args(ContractInstanceWithAddressSchema, optional(ContractArtifactSchema), optional(schemas.Fr))
561568
.returns(ContractInstanceWithAddressSchema),
569+
registerContractClass: z.function().args(ContractArtifactSchema).returns(z.void()),
562570
simulateTx: z
563571
.function()
564572
.args(ExecutionPayloadSchema, SimulateOptionsSchema)

0 commit comments

Comments
 (0)