Skip to content

Commit e87c61a

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. `ContractOverrides.artifact` becomes optional: `proxied_contract_data_source` falls through to the locally registered class when the override has no artifact, so the helper's instance-only entry resolves private dispatch through the user's existing contract store.
1 parent df229d1 commit e87c61a

13 files changed

Lines changed: 265 additions & 15 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
@@ -87,6 +87,24 @@ Use this to:
8787
- Reproduce a bug from production by pinning storage to the values seen at a specific block
8888
- Test branches that depend on rare values without orchestrating the contract calls that produce them
8989

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

92110
- [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
@@ -34,6 +34,19 @@ const result = await contract.methods.read_balance(account).simulate({
3434

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

37+
`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 on-chain upgrade flow, use the `fastForwardContractUpdate` helper which returns a `SimulationOverrides` covering both registry storage rewrites and the upgraded instance entry:
38+
39+
```typescript
40+
import { fastForwardContractUpdate } from '@aztec/aztec.js';
41+
42+
const overrides = await fastForwardContractUpdate({
43+
instanceAddress: contract.address,
44+
newClassId: upgradedClass.id,
45+
node,
46+
});
47+
const result = await contract.methods.upgraded_method().simulate({ overrides });
48+
```
49+
3750
### [PXE] `proveTx` takes an options bag
3851

3952
`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,
@@ -1549,7 +1549,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
15491549
maxDebugLogMemoryReads: this.config.rpcSimulatePublicMaxDebugLogMemoryReads,
15501550
}),
15511551
});
1552-
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config);
1552+
const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings());
1553+
if (overrides?.contracts) {
1554+
contractsDB.addContracts(Object.values(overrides.contracts).map(({ instance }) => instance));
1555+
}
1556+
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config, contractsDB);
15531557

15541558
// REFACTOR: Consider merging ProcessReturnValues into ProcessedTx
15551559
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
@@ -231,6 +231,17 @@ describe('Contract Class', () => {
231231
expect(result).toBe(42n);
232232
});
233233

234+
it('throws when overrides are passed to a utility function simulation', async () => {
235+
const fooContract = Contract.at(contractAddress, defaultArtifact, wallet);
236+
await expect(
237+
fooContract.methods.qux(123n).simulate({
238+
from: account.getAddress(),
239+
overrides: { publicStorage: [{ contract: contractAddress, slot: new Fr(1), value: new Fr(42) }] },
240+
}),
241+
).rejects.toThrow(/not supported for utility/);
242+
expect(wallet.executeUtility).not.toHaveBeenCalled();
243+
});
244+
234245
it('should extract offchain messages with anchor block timestamp on simulate', async () => {
235246
const recipient = await AztecAddress.random();
236247
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: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 a `SimulationOverrides` blob that simulates 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+
* driving both AVM-side public dispatch and PXE-side ACIR private dispatch.
20+
*
21+
* The new class must already be registered on chain.
22+
*
23+
* @param args.instanceAddress - Address of the deployed instance to upgrade.
24+
* @param args.newClassId - ID of the (already-registered) class to upgrade to.
25+
* @param args.node - Node used to fetch the existing instance and validate the class is registered.
26+
* @returns `SimulationOverrides` to pass as `.simulate({ overrides })`.
27+
* @throws If the instance is not deployed, the class is not registered on chain, or the instance is already on the target class.
28+
*/
29+
export async function fastForwardContractUpdate(args: {
30+
instanceAddress: AztecAddress;
31+
newClassId: Fr;
32+
node: AztecNode;
33+
}): Promise<SimulationOverrides> {
34+
const { instanceAddress, newClassId, node } = args;
35+
36+
const instance = await node.getContract(instanceAddress);
37+
if (!instance) {
38+
throw new Error(`Instance not deployed at ${instanceAddress}; deploy it before fast-forwarding an update`);
39+
}
40+
41+
const klass = await node.getContractClass(newClassId);
42+
if (!klass) {
43+
throw new Error(`Contract class ${newClassId} is not registered on chain; publish it before fast-forwarding to it`);
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+
// `pre = 0` is the canonical "first upgrade" form: the C++ check falls back to `originalContractClassId`.
53+
const svc = new ScheduledValueChange([new Fr(0)], [newClassId], 1n);
54+
const sdc = ScheduledDelayChange.empty();
55+
const dpmv = new DelayedPublicMutableValuesWithHash(svc, sdc);
56+
57+
const { delayedPublicMutableSlot } = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
58+
const fields = await dpmv.toFields();
59+
60+
const publicStorage = fields.map((value, i) => ({
61+
contract: ProtocolContractAddress.ContractInstanceRegistry,
62+
slot: delayedPublicMutableSlot.add(new Fr(i)),
63+
value,
64+
}));
65+
66+
const upgradedInstance = { ...instance, currentContractClassId: newClassId };
67+
68+
return new SimulationOverrides({
69+
publicStorage,
70+
contracts: { [instanceAddress.toString()]: { instance: upgradedInstance } },
71+
});
72+
}

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

Lines changed: 31 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';
@@ -178,4 +178,34 @@ describe('e2e_contract_updates', () => {
178178
'Could not update contract to a class different from the current one',
179179
);
180180
});
181+
182+
// UpdatableContract's `set_public_value(Field)` and UpdatedContract's `set_public_value()`
183+
// have different function selectors. Without an upgrade, only the deployed Updatable's
184+
// (Field) selector exists; with a fastForwardContractUpdate override, the AVM dispatches
185+
// against UpdatedContract's bytecode and the no-args selector resolves.
186+
it('fastForwardContractUpdate enables simulation of post-upgrade public calls', async () => {
187+
// Local construction with the new artifact - no PXE/wallet side effect, no chain mutation.
188+
const updatedContract = UpdatedContract.at(contract.address, wallet);
189+
190+
// Without overrides, UpdatedContract's no-args selector doesn't match the deployed class.
191+
await expect(
192+
updatedContract.methods.set_public_value().simulate({ from: defaultAccountAddress }),
193+
).rejects.toThrow();
194+
195+
// With the fastForwardContractUpdate overrides, the AVM dispatches against UpdatedContract's
196+
// bytecode and the call simulates successfully.
197+
const overrides = await fastForwardContractUpdate({
198+
instanceAddress: contract.address,
199+
newClassId: updatedContractClassId,
200+
node: aztecNode,
201+
});
202+
await expect(
203+
updatedContract.methods.set_public_value().simulate({ from: defaultAccountAddress, overrides }),
204+
).resolves.toBeDefined();
205+
206+
// Chain state is untouched: the original Updatable's set_public_value(Field) still simulates fine.
207+
await expect(
208+
contract.methods.set_public_value(5678n).simulate({ from: defaultAccountAddress }),
209+
).resolves.toBeDefined();
210+
});
181211
});

yarn-project/simulator/src/public/public_db_sources.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export class PublicContractsDB implements PublicContractsDBInterface {
5555
this.log = createLogger('simulator:contracts-data-source', bindings);
5656
}
5757

58-
public addContracts(contractDeploymentData: ContractDeploymentData): void {
58+
/** Parses raw log data from the C++/NAPI bridge and inserts the resulting contracts into the current checkpoint. */
59+
public addContractsFromLogs(contractDeploymentData: ContractDeploymentData): void {
5960
const currentState = this.getCurrentState();
6061

6162
this.addContractClassesFromEvents(
@@ -71,8 +72,16 @@ export class PublicContractsDB implements PublicContractsDBInterface {
7172

7273
public addNewContracts(tx: Tx): void {
7374
const contractDeploymentData = AllContractDeploymentData.fromTx(tx);
74-
this.addContracts(contractDeploymentData.getNonRevertibleContractDeploymentData());
75-
this.addContracts(contractDeploymentData.getRevertibleContractDeploymentData());
75+
this.addContractsFromLogs(contractDeploymentData.getNonRevertibleContractDeploymentData());
76+
this.addContractsFromLogs(contractDeploymentData.getRevertibleContractDeploymentData());
77+
}
78+
79+
/** Inserts typed contract instances directly into the current checkpoint. */
80+
public addContracts(contractInstances?: ContractInstanceWithAddress[]): void {
81+
const currentState = this.getCurrentState();
82+
for (const instance of contractInstances ?? []) {
83+
currentState.addInstance(instance.address, instance);
84+
}
7685
}
7786

7887
/**

0 commit comments

Comments
 (0)