Skip to content

Commit 75faddf

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 75faddf

17 files changed

Lines changed: 211 additions & 98 deletions

File tree

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

Lines changed: 14 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`: contract-DB overrides keyed by address - per-address `instance` (always applied) and an optional `artifact` (for redirecting private-function dispatch).
7376

7477
Override a public-storage slot:
7578

@@ -81,26 +84,30 @@ 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 both `stateOverrides` (node-side, public dispatch) and `contractOverrides` (PXE-side, private dispatch) so a single spread covers any mix of private and public function calls on the upgraded contract.
9399

94100
```typescript
95101
import { fastForwardContractUpdate } from '@aztec/aztec.js';
96102

97-
const stateOverrides = await fastForwardContractUpdate({
103+
const overrides = await fastForwardContractUpdate({
98104
instanceAddress: contract.address,
99-
newClassId: upgradedClass.id,
105+
newArtifact: UpdatedContract.artifact,
100106
node,
101107
});
102108

103-
const result = await contract.methods.upgraded_method().simulate({ stateOverrides });
109+
const upgradedContract = UpdatedContract.at(contract.address, wallet);
110+
const result = await upgradedContract.methods.upgraded_method().simulate({ ...overrides });
104111
```
105112

106113
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: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,20 @@ 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 address-keyed map of `{ instance, artifact? }` that replaces the contract DB entry the simulator uses for that address. The instance is applied on both the AVM (public dispatch) and PXE (private dispatch via ACIR) sides; the optional artifact controls private-call ACIR lookup.
27+
28+
To simulate a complete on-chain upgrade flow, use the `fastForwardContractUpdate` helper. It returns both `stateOverrides` (state-tree writes - registry public storage) and `contractOverrides` (instance/artifact bumped to the new class) so a single spread covers any mix of call flavors:
2729

2830
```typescript
2931
import { fastForwardContractUpdate } from '@aztec/aztec.js';
3032

31-
const stateOverrides = await fastForwardContractUpdate({
33+
const overrides = await fastForwardContractUpdate({
3234
instanceAddress: contract.address,
33-
newClassId: upgradedClass.id,
35+
newArtifact: UpdatedContract.artifact,
3436
node,
3537
});
36-
const result = await contract.methods.upgraded_method().simulate({ stateOverrides });
38+
const upgradedContract = UpdatedContract.at(contract.address, wallet);
39+
const result = await upgradedContract.methods.upgraded_method().simulate({ ...overrides });
3740
```
3841

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

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import type { NullifierLeafPreimage, PublicDataTreeLeafPreimage } from '@aztec/s
100100
import { MerkleTreeId, NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees';
101101
import {
102102
type BlockHeader,
103+
type ContractOverrides,
103104
type FeeProvider,
104105
type GlobalVariableBuilder as GlobalVariableBuilderInterface,
105106
type IndexedTxEffect,
@@ -1484,7 +1485,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
14841485
* Simulates the public part of a transaction with the current state.
14851486
* @param tx - The transaction to simulate.
14861487
* @param skipFeeEnforcement - If true, fee enforcement is skipped.
1487-
* @param stateOverrides - Optional state overrides applied to the ephemeral fork and contract DB before simulation.
1488+
* @param stateOverrides - Optional state-tree overrides (publicStorage writes) applied to the ephemeral fork.
1489+
* @param contractOverrides - Optional contract-DB overrides; per-address `instance` is used for AVM dispatch.
14881490
**/
14891491
@trackSpan('AztecNodeService.simulatePublicCalls', (tx: Tx) => ({
14901492
[Attributes.TX_HASH]: tx.getTxHash().toString(),
@@ -1493,6 +1495,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
14931495
tx: Tx,
14941496
skipFeeEnforcement = false,
14951497
stateOverrides?: StateOverrides,
1498+
contractOverrides?: ContractOverrides,
14961499
): Promise<PublicSimulationOutput> {
14971500
// Check total gas limit for simulation
14981501
const gasSettings = tx.data.constants.txContext.gasSettings;
@@ -1550,7 +1553,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
15501553
}),
15511554
});
15521555
const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings());
1553-
contractsDB.addContracts(stateOverrides?.contractInstances);
1556+
const overrideInstances = contractOverrides
1557+
? Object.values(contractOverrides).map(({ instance }) => instance)
1558+
: undefined;
1559+
contractsDB.addContracts(overrideInstances);
15541560
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config, contractsDB);
15551561

15561562
// REFACTOR: Consider merging ProcessReturnValues into ProcessedTx

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,20 @@ 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: {
251+
[contractAddress.toString()]: { instance: contractInstance, artifact: defaultArtifact },
252+
},
253+
}),
254+
).rejects.toThrow(/not supported for utility function/);
242255
expect(wallet.executeUtility).not.toHaveBeenCalled();
243256
});
244257

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ export class ContractFunctionInteraction extends BaseContractInteraction {
130130
// docs:end:simulate
131131
if (this.functionDao.functionType == FunctionType.UTILITY) {
132132
if (
133-
options.stateOverrides &&
134-
(options.stateOverrides.publicStorage?.length || options.stateOverrides.contractInstances?.length)
133+
options.stateOverrides?.publicStorage?.length ||
134+
(options.contractOverrides && Object.keys(options.contractOverrides).length > 0)
135135
) {
136-
throw new Error('stateOverrides are not supported for utility function simulation.');
136+
throw new Error('stateOverrides and contractOverrides are not supported for utility function simulation.');
137137
}
138138
const call = await this.getFunctionCall();
139139
const scopes = [...(options.additionalScopes ?? [])];
Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Fr } from '@aztec/foundation/curves/bn254';
22
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
3+
import type { ContractArtifact } from '@aztec/stdlib/abi';
34
import { AztecAddress } from '@aztec/stdlib/aztec-address';
4-
import { SerializableContractInstance } from '@aztec/stdlib/contract';
5+
import { SerializableContractInstance, getContractClassFromArtifact } from '@aztec/stdlib/contract';
56
import {
67
DELAYED_PUBLIC_MUTABLE_VALUES_LEN,
78
DelayedPublicMutableValuesWithHash,
89
} from '@aztec/stdlib/delayed-public-mutable';
910
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
11+
import { getTestContractArtifact, getTokenContractArtifact } from '@aztec/stdlib/testing/fixtures';
1012

1113
import { type MockProxy, mock } from 'jest-mock-extended';
1214

@@ -16,13 +18,15 @@ describe('fastForwardContractUpdate', () => {
1618
let node: MockProxy<AztecNode>;
1719
let instanceAddress: AztecAddress;
1820
let originalClassId: Fr;
21+
let newArtifact: ContractArtifact;
1922
let newClassId: Fr;
2023

2124
beforeEach(async () => {
2225
node = mock<AztecNode>();
2326
instanceAddress = await AztecAddress.random();
24-
originalClassId = Fr.random();
25-
newClassId = Fr.random();
27+
originalClassId = (await getContractClassFromArtifact(getTokenContractArtifact())).id;
28+
newArtifact = getTestContractArtifact();
29+
newClassId = (await getContractClassFromArtifact(newArtifact)).id;
2630

2731
const instance = (
2832
await SerializableContractInstance.random({
@@ -32,48 +36,42 @@ describe('fastForwardContractUpdate', () => {
3236
).withAddress(instanceAddress);
3337

3438
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);
39+
node.getContractClass.mockResolvedValue({ id: newClassId } as any);
4740
});
4841

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);
42+
it('produces stateOverrides with registry storage writes', async () => {
43+
const { stateOverrides } = await fastForwardContractUpdate({ instanceAddress, newArtifact, node });
5644

5745
const expectedSlots = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
58-
expect(overrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
59-
for (const entry of overrides.publicStorage!) {
46+
expect(stateOverrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
47+
for (const entry of stateOverrides.publicStorage!) {
6048
expect(entry.contract).toEqual(ProtocolContractAddress.ContractInstanceRegistry);
6149
}
62-
const baseSlot = expectedSlots.delayedPublicMutableSlot;
63-
expect(overrides.publicStorage![0].slot).toEqual(baseSlot);
64-
expect(overrides.publicStorage![overrides.publicStorage!.length - 1].slot).toEqual(
50+
expect(stateOverrides.publicStorage![0].slot).toEqual(expectedSlots.delayedPublicMutableSlot);
51+
expect(stateOverrides.publicStorage![stateOverrides.publicStorage!.length - 1].slot).toEqual(
6552
expectedSlots.delayedPublicMutableHashSlot,
6653
);
6754
});
6855

56+
it('produces contractOverrides keyed by the instance address with the new artifact', async () => {
57+
const { contractOverrides } = await fastForwardContractUpdate({ instanceAddress, newArtifact, node });
58+
59+
const entry = contractOverrides[instanceAddress.toString()];
60+
expect(entry).toBeDefined();
61+
expect(entry.artifact).toBe(newArtifact);
62+
expect(entry.instance.address).toEqual(instanceAddress);
63+
expect(entry.instance.currentContractClassId).toEqual(newClassId);
64+
expect(entry.instance.originalContractClassId).toEqual(originalClassId);
65+
});
66+
6967
it('throws when the instance is not deployed', async () => {
7068
node.getContract.mockResolvedValue(undefined);
71-
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not deployed/);
69+
await expect(fastForwardContractUpdate({ instanceAddress, newArtifact, node })).rejects.toThrow(/not deployed/);
7270
});
7371

7472
it('throws when the new class is not registered', async () => {
7573
node.getContractClass.mockResolvedValue(undefined);
76-
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not registered/);
74+
await expect(fastForwardContractUpdate({ instanceAddress, newArtifact, node })).rejects.toThrow(/not registered/);
7775
});
7876

7977
it('throws when the instance is already on the target class', async () => {
@@ -85,6 +83,6 @@ describe('fastForwardContractUpdate', () => {
8583
).withAddress(instanceAddress);
8684
node.getContract.mockResolvedValue(sameClassInstance);
8785

88-
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/already on class/);
86+
await expect(fastForwardContractUpdate({ instanceAddress, newArtifact, node })).rejects.toThrow(/already on class/);
8987
});
9088
});

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

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,47 @@
11
import { Fr } from '@aztec/foundation/curves/bn254';
22
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
3+
import type { ContractArtifact } from '@aztec/stdlib/abi';
34
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
5+
import { getContractClassFromArtifact } from '@aztec/stdlib/contract';
46
import {
57
DelayedPublicMutableValuesWithHash,
68
ScheduledDelayChange,
79
ScheduledValueChange,
810
} from '@aztec/stdlib/delayed-public-mutable';
911
import type { AztecNode, StateOverrides } from '@aztec/stdlib/interfaces/client';
12+
import type { ContractOverrides } from '@aztec/stdlib/tx';
1013

1114
/**
12-
* Builds `StateOverrides` that simulate a contract instance having already been upgraded to a new contract class.
15+
* Builds the override blobs that simulate a contract instance having already been upgraded to a new contract class.
1316
*
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.
17+
* Mirrors a real on-chain upgrade flow (`pxe.updateContract` followed by waiting out the delay). Returns two
18+
* coherent override layers:
1719
*
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).
20+
* - `stateOverrides` — applied node-side. Bumps `currentContractClassId` on the contract instance and rewrites
21+
* the `ContractInstanceRegistry`'s delayed-public-mutable storage to look like the upgrade was scheduled in
22+
* the past. Redirects public-call dispatch.
23+
* - `contractOverrides` — applied PXE-side. Replaces the (instance, artifact) pair the ACIR simulator uses for
24+
* the upgraded address. Redirects private-call dispatch.
25+
*
26+
* Both layers are returned so a single `simulate({ ...overrides })` spread works for any mix of private and
27+
* public function calls on the upgraded contract.
28+
*
29+
* The new class must already be registered on chain. To upgrade to an unregistered class, register it first.
2030
*
2131
* @param args.instanceAddress - Address of the deployed instance to upgrade.
22-
* @param args.newClassId - ID of the (already-registered) class to upgrade to.
32+
* @param args.newArtifact - Artifact of the class to upgrade to. Must be registered on chain.
2333
* @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.
34+
* @returns `{ stateOverrides, contractOverrides }` to spread into a `simulate(...)` call.
2535
* @throws If the instance is not deployed, the class is not registered, or the instance is already on the target class.
2636
*/
2737
export async function fastForwardContractUpdate(args: {
2838
instanceAddress: AztecAddress;
29-
newClassId: Fr;
39+
newArtifact: ContractArtifact;
3040
node: AztecNode;
31-
}): Promise<StateOverrides> {
32-
const { instanceAddress, newClassId, node } = args;
41+
}): Promise<{ stateOverrides: StateOverrides; contractOverrides: ContractOverrides }> {
42+
const { instanceAddress, newArtifact, node } = args;
43+
44+
const newClassId = (await getContractClassFromArtifact(newArtifact)).id;
3345

3446
const instance = await node.getContract(instanceAddress);
3547
if (!instance) {
@@ -63,8 +75,14 @@ export async function fastForwardContractUpdate(args: {
6375

6476
const upgradedInstance = { ...instance, currentContractClassId: newClassId };
6577

66-
return {
67-
publicStorage,
68-
contractInstances: [upgradedInstance],
78+
const stateOverrides: StateOverrides = { publicStorage };
79+
80+
const contractOverrides: ContractOverrides = {
81+
[instanceAddress.toString()]: {
82+
instance: upgradedInstance,
83+
artifact: newArtifact,
84+
},
6985
};
86+
87+
return { stateOverrides, contractOverrides };
7088
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas';
66
import type { StateOverrides } from '@aztec/stdlib/interfaces/client';
77
import {
88
type Capsule,
9+
type ContractOverrides,
910
OFFCHAIN_MESSAGE_IDENTIFIER,
1011
type OffchainEffect,
1112
type SimulationStats,
@@ -160,6 +161,12 @@ export type SimulateInteractionOptions = Omit<SendInteractionOptions, 'fee'> & {
160161
includeMetadata?: boolean;
161162
/** Pre-simulation state overrides applied to the ephemeral fork and contract DB. */
162163
stateOverrides?: StateOverrides;
164+
/**
165+
* Per-simulation contract overrides applied PXE-side. For each address, replaces the (instance, artifact)
166+
* pair the ACIR simulator uses for the duration of the simulation. Required to redirect private-function
167+
* dispatch when simulating against an upgraded class.
168+
*/
169+
contractOverrides?: ContractOverrides;
163170
};
164171

165172
/**

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { AbiDecodedSchema, type ApiSchemaFor, optional, schemas, zodFor } from '
1919
import type { ExecutionPayload, InTx } from '@aztec/stdlib/tx';
2020
import {
2121
Capsule,
22+
ContractOverridesSchema,
2223
HashedValues,
2324
TxHash,
2425
TxProfileResult,
@@ -337,6 +338,7 @@ export const SimulateOptionsSchema = z.object({
337338
includeMetadata: optional(z.boolean()),
338339
additionalScopes: optional(z.array(schemas.AztecAddress)),
339340
stateOverrides: optional(StateOverridesSchema),
341+
contractOverrides: optional(ContractOverridesSchema),
340342
});
341343

342344
export const ProfileOptionsSchema = SimulateOptionsSchema.extend({

0 commit comments

Comments
 (0)