Skip to content

Commit bc31ce0

Browse files
committed
feat: fastForwardContractUpdate cheatcode for simulating contract updates
Adds a high-level helper that returns a `SimulationOverrides` blob simulating a deployed instance as if it had already been upgraded to a new contract class: - `overrides.publicStorage` rewrites the `ContractInstanceRegistry`'s delayed-public-mutable storage so the AVM's `UpdateCheck` resolves to the new class id. - `overrides.contracts` swaps the deployed instance for one whose `currentContractClassId` is bumped to the new class. Drives both AVM-side public dispatch and PXE-side ACIR private dispatch. Both pieces are required: a storage-only override would not redirect the AVM's class dispatch (which reads `currentContractClassId` from the contract DB); an instance-only override would cause the witgen `UpdateCheck` to throw on inconsistency. The new class must already be registered on chain.
1 parent 4f39410 commit bc31ce0

14 files changed

Lines changed: 290 additions & 17 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ const result = await contract.methods.read_balance(account).simulate({
8383

8484
Use this to set up state preconditions, reproduce production bugs against pinned storage, or exercise rare value branches without orchestrating the contract calls that produce them.
8585

86+
### Fast-forwarding a contract update
87+
88+
`fastForwardContractUpdate` returns a `SimulationOverrides` object that simulates 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.
89+
90+
```typescript
91+
import { fastForwardContractUpdate } from '@aztec/aztec.js';
92+
93+
const overrides = await fastForwardContractUpdate({
94+
instanceAddress: contract.address,
95+
newClassId: upgradedClass.id,
96+
node,
97+
});
98+
99+
const result = await contract.methods.upgraded_method().simulate({ overrides });
100+
```
101+
102+
Use this to test code paths that only execute after an upgrade, without orchestrating the full delayed-mutable upgrade flow.
103+
86104
## Further reading
87105

88106
- [How to read contract data](./how_to_read_data.md)

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ Direct callers of the `SimulationOverrides` constructor must switch from a posit
185185
+ new SimulationOverrides({ contracts });
186186
```
187187

188+
`overrides.contracts` swaps 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 onchain upgrade flow, use the `fastForwardContractUpdate` helper which returns a `SimulationOverrides` covering both registry storage rewrites and the upgraded instance entry:
189+
190+
```typescript
191+
import { fastForwardContractUpdate } from '@aztec/aztec.js';
192+
193+
const overrides = await fastForwardContractUpdate({
194+
instanceAddress: contract.address,
195+
newClassId: upgradedClass.id,
196+
node,
197+
});
198+
const result = await contract.methods.upgraded_method().simulate({ overrides });
199+
```
200+
188201
### [PXE] `proveTx` takes an options bag
189202

190203
`PXE.proveTx` used to accept `scopes` as a positional argument; it now takes an options bag consistent with `simulateTx` and `profileTx`, and adds an optional `senderForTags` field. Update direct callers:

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
SequencerClient,
4040
type SequencerPublisher,
4141
} from '@aztec/sequencer-client';
42-
import { PublicProcessorFactory } from '@aztec/simulator/server';
42+
import { PublicContractsDB, PublicProcessorFactory } from '@aztec/simulator/server';
4343
import {
4444
AttestationsBlockWatcher,
4545
EpochPruneWatcher,
@@ -1508,7 +1508,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
15081508
maxDebugLogMemoryReads: this.config.rpcSimulatePublicMaxDebugLogMemoryReads,
15091509
}),
15101510
});
1511-
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config);
1511+
const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings());
1512+
if (overrides?.contracts) {
1513+
contractsDB.addContracts(Object.values(overrides.contracts).map(({ instance }) => instance));
1514+
}
1515+
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config, contractsDB);
15121516

15131517
// REFACTOR: Consider merging ProcessReturnValues into ProcessedTx
15141518
const [processedTxs, failedTxs, _usedTxs, returns, debugLogs] = await processor.process([tx]);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export {
7474
} from '../contract/deploy_method.js';
7575
export { waitForProven, type WaitForProvenOpts, DefaultWaitForProvenOpts } from '../contract/wait_for_proven.js';
7676
export { getGasLimits } from '../contract/get_gas_limits.js';
77+
export { fastForwardContractUpdate } from '../contract/fastforward_contract_update.js';
7778

7879
export {
7980
type PartialAddress,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ describe('Contract Class', () => {
7676
expect(result).toBe(42n);
7777
});
7878

79+
it('throws when overrides are passed to a utility function simulation', async () => {
80+
const fooContract = Contract.at(contractAddress, testContractArtifact, wallet);
81+
await expect(
82+
fooContract.methods.qux(123n).simulate({
83+
from: account.getAddress(),
84+
overrides: { publicStorage: [{ contract: contractAddress, slot: new Fr(1), value: new Fr(42) }] },
85+
}),
86+
).rejects.toThrow(/not supported for utility/);
87+
expect(wallet.executeUtility).not.toHaveBeenCalled();
88+
});
89+
7990
it('should extract offchain messages with anchor block timestamp on simulate', async () => {
8091
const recipient = await AztecAddress.random();
8192
const msgPayload = [Fr.random(), Fr.random()];

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ export class ContractFunctionInteraction extends BaseContractInteraction {
129129
): Promise<SimulationResult> {
130130
// docs:end:simulate
131131
if (this.functionDao.functionType == FunctionType.UTILITY) {
132+
if (options.overrides?.publicStorage?.length || options.overrides?.contracts) {
133+
throw new Error('overrides are not supported for utility function simulation.');
134+
}
132135
const call = await this.getFunctionCall();
133136
const scopes = [...(options.additionalScopes ?? [])];
134137
const utilityResult = await this.wallet.executeUtility(call, {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Fr } from '@aztec/foundation/curves/bn254';
2+
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
3+
import { AztecAddress } from '@aztec/stdlib/aztec-address';
4+
import { SerializableContractInstance } from '@aztec/stdlib/contract';
5+
import {
6+
DELAYED_PUBLIC_MUTABLE_VALUES_LEN,
7+
DelayedPublicMutableValuesWithHash,
8+
} from '@aztec/stdlib/delayed-public-mutable';
9+
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
10+
11+
import { type MockProxy, mock } from 'jest-mock-extended';
12+
13+
import { fastForwardContractUpdate } from './fastforward_contract_update.js';
14+
15+
describe('fastForwardContractUpdate', () => {
16+
let node: MockProxy<AztecNode>;
17+
let instanceAddress: AztecAddress;
18+
let originalClassId: Fr;
19+
let newClassId: Fr;
20+
21+
beforeEach(async () => {
22+
node = mock<AztecNode>();
23+
instanceAddress = await AztecAddress.random();
24+
originalClassId = Fr.random();
25+
newClassId = Fr.random();
26+
27+
const instance = (
28+
await SerializableContractInstance.random({
29+
currentContractClassId: originalClassId,
30+
originalContractClassId: originalClassId,
31+
})
32+
).withAddress(instanceAddress);
33+
34+
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);
47+
});
48+
49+
it('produces overrides with bumped currentContractClassId and registry storage writes', async () => {
50+
const overrides = await fastForwardContractUpdate({ instanceAddress, newClassId, node });
51+
52+
const upgraded = overrides.contracts?.[instanceAddress.toString()];
53+
expect(upgraded).toBeDefined();
54+
expect(upgraded!.instance.address).toEqual(instanceAddress);
55+
expect(upgraded!.instance.currentContractClassId).toEqual(newClassId);
56+
expect(upgraded!.instance.originalContractClassId).toEqual(originalClassId);
57+
58+
const expectedSlots = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
59+
expect(overrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
60+
for (const entry of overrides.publicStorage!) {
61+
expect(entry.contract).toEqual(ProtocolContractAddress.ContractInstanceRegistry);
62+
}
63+
const baseSlot = expectedSlots.delayedPublicMutableSlot;
64+
expect(overrides.publicStorage![0].slot).toEqual(baseSlot);
65+
expect(overrides.publicStorage![overrides.publicStorage!.length - 1].slot).toEqual(
66+
expectedSlots.delayedPublicMutableHashSlot,
67+
);
68+
});
69+
70+
it('throws when the instance is not deployed', async () => {
71+
node.getContract.mockResolvedValue(undefined);
72+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not deployed/);
73+
});
74+
75+
it('throws when the new class is not registered', async () => {
76+
node.getContractClass.mockResolvedValue(undefined);
77+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not registered/);
78+
});
79+
80+
it('throws when the instance is already on the target class', async () => {
81+
const sameClassInstance = (
82+
await SerializableContractInstance.random({
83+
currentContractClassId: newClassId,
84+
originalContractClassId: originalClassId,
85+
})
86+
).withAddress(instanceAddress);
87+
node.getContract.mockResolvedValue(sameClassInstance);
88+
89+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/already on class/);
90+
});
91+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Fr } from '@aztec/foundation/curves/bn254';
2+
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
3+
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
4+
import {
5+
DelayedPublicMutableValuesWithHash,
6+
ScheduledDelayChange,
7+
ScheduledValueChange,
8+
} from '@aztec/stdlib/delayed-public-mutable';
9+
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
10+
import { SimulationOverrides } from '@aztec/stdlib/tx';
11+
12+
/**
13+
* Builds `SimulationOverrides` that simulate a deployed instance as if it had already been upgraded to a
14+
* new contract class. Mirrors a real on-chain upgrade (`pxe.updateContract` followed by waiting out the delay):
15+
*
16+
* - `publicStorage` rewrites the `ContractInstanceRegistry`'s delayed-public-mutable storage so the AVM's
17+
* `UpdateCheck` resolves to the new class id.
18+
* - `contracts` swaps the deployed instance for one whose `currentContractClassId` is bumped to the new class.
19+
*
20+
* The new class must already be registered on chain.
21+
*
22+
* @throws If the instance is not deployed, the class is not registered on chain, or the instance is already on the target class.
23+
*/
24+
export async function fastForwardContractUpdate(args: {
25+
/** Address of the deployed instance to upgrade. */
26+
instanceAddress: AztecAddress;
27+
/** ID of the (already-registered) class to upgrade to. */
28+
newClassId: Fr;
29+
/** Node used to fetch the existing instance and validate the class is registered. */
30+
node: AztecNode;
31+
}): Promise<SimulationOverrides> {
32+
const { instanceAddress, newClassId, node } = args;
33+
34+
const instance = await node.getContract(instanceAddress);
35+
if (!instance) {
36+
throw new Error(`Instance not deployed at ${instanceAddress}. Deploy it before fast-forwarding an update.`);
37+
}
38+
39+
const klass = await node.getContractClass(newClassId);
40+
if (!klass) {
41+
throw new Error(
42+
`Contract class ${newClassId} is not registered on chain. Publish it before fast-forwarding to it.`,
43+
);
44+
}
45+
46+
if (instance.currentContractClassId.equals(newClassId)) {
47+
throw new Error(`Instance ${instanceAddress} is already on class ${newClassId}. Nothing to fast-forward.`);
48+
}
49+
50+
// Build the SVC the same way `ContractInstanceRegistry::update` would have, but with a timestamp_of_change
51+
// safely in the past so the AVM's UpdateCheck resolves to the post-upgrade class id at any sim timestamp.
52+
const svc = new ScheduledValueChange(/*previous=*/ [new Fr(0)], /*post=*/ [newClassId], /*timestampOfChange=*/ 1n);
53+
const sdc = ScheduledDelayChange.empty();
54+
const dpmv = new DelayedPublicMutableValuesWithHash(svc, sdc);
55+
56+
const { delayedPublicMutableSlot } = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
57+
const fields = await dpmv.toFields();
58+
59+
const publicStorage = fields.map((value, i) => ({
60+
contract: ProtocolContractAddress.ContractInstanceRegistry,
61+
slot: delayedPublicMutableSlot.add(new Fr(i)),
62+
value,
63+
}));
64+
65+
const upgradedInstance = { ...instance, currentContractClassId: newClassId };
66+
67+
return new SimulationOverrides({
68+
publicStorage,
69+
contracts: { [instanceAddress.toString()]: { instance: upgradedInstance } },
70+
});
71+
}

yarn-project/end-to-end/src/e2e_avm_simulator.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { BatchCall, type ContractInstanceWithAddress } from '@aztec/aztec.js/con
33
import { Fr } from '@aztec/aztec.js/fields';
44
import type { AztecNode } from '@aztec/aztec.js/node';
55
import { TxExecutionResult } from '@aztec/aztec.js/tx';
6-
import type { PublicStorageOverride } from '@aztec/aztec.js/wallet';
7-
import type { Wallet } from '@aztec/aztec.js/wallet';
6+
import type { PublicStorageOverride, Wallet } from '@aztec/aztec.js/wallet';
87
import { AvmInitializerTestContract } from '@aztec/noir-test-contracts.js/AvmInitializerTest';
98
import { AvmTestContract } from '@aztec/noir-test-contracts.js/AvmTest';
109

yarn-project/end-to-end/src/e2e_contract_updates.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getSchnorrAccountContractAddress } from '@aztec/accounts/schnorr';
2-
import { getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
2+
import { fastForwardContractUpdate, getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
33
import { publishContractClass } from '@aztec/aztec.js/deployment';
44
import { Fr } from '@aztec/aztec.js/fields';
55
import type { AztecNode } from '@aztec/aztec.js/node';
@@ -177,4 +177,59 @@ describe('e2e_contract_updates', () => {
177177
'Could not update contract to a class different from the current one',
178178
);
179179
});
180+
181+
// UpdatableContract's `set_public_value(Field)` and UpdatedContract's `set_public_value()`
182+
// have different function selectors. Without an upgrade, only the deployed Updatable's
183+
// (Field) selector exists; with a fastForwardContractUpdate override, the AVM dispatches
184+
// against UpdatedContract's bytecode and the no-args selector resolves.
185+
it('fastForwardContractUpdate enables simulation of post-upgrade public calls', async () => {
186+
// Local construction with the new artifact - no PXE/wallet side effect, no chain mutation.
187+
const updatedContract = UpdatedContract.at(contract.address, wallet);
188+
189+
// Without overrides, UpdatedContract's no-args selector doesn't match the deployed class.
190+
await expect(
191+
updatedContract.methods.set_public_value().simulate({ from: defaultAccountAddress }),
192+
).rejects.toThrow();
193+
194+
// With the fastForwardContractUpdate overrides, the AVM dispatches against UpdatedContract's
195+
// bytecode and the call simulates successfully.
196+
const overrides = await fastForwardContractUpdate({
197+
instanceAddress: contract.address,
198+
newClassId: updatedContractClassId,
199+
node: aztecNode,
200+
});
201+
await expect(
202+
updatedContract.methods.set_public_value().simulate({ from: defaultAccountAddress, overrides }),
203+
).resolves.toBeDefined();
204+
205+
// Chain state is untouched: the original Updatable's set_public_value(Field) still simulates fine.
206+
await expect(
207+
contract.methods.set_public_value(5678n).simulate({ from: defaultAccountAddress }),
208+
).resolves.toBeDefined();
209+
});
210+
211+
// UpdatedContract.set_private_value is a private function that doesn't exist on UpdatableContract.
212+
// For PXE-side ACIR dispatch to find it, the artifact must be registered locally first via
213+
// wallet.registerContractClass; the helper itself only takes the class id.
214+
it('fastForwardContractUpdate enables simulation of post-upgrade private calls', async () => {
215+
const updatedContract = UpdatedContract.at(contract.address, wallet);
216+
217+
// Without overrides (and without local artifact registration), the new private function isn't
218+
// available on the deployed class.
219+
await expect(
220+
updatedContract.methods.set_private_value().simulate({ from: defaultAccountAddress }),
221+
).rejects.toThrow();
222+
223+
// Register the new artifact in the local PXE so the ACIR simulator can find its private functions.
224+
await wallet.registerContractClass(UpdatedContract.artifact);
225+
226+
const overrides = await fastForwardContractUpdate({
227+
instanceAddress: contract.address,
228+
newClassId: updatedContractClassId,
229+
node: aztecNode,
230+
});
231+
await expect(
232+
updatedContract.methods.set_private_value().simulate({ from: defaultAccountAddress, overrides }),
233+
).resolves.toBeDefined();
234+
});
180235
});

0 commit comments

Comments
 (0)