Skip to content

Commit 7e12172

Browse files
committed
feat: fastForwardContractUpdate cheatcode for simulating contract updates
Adds a high-level helper that simulates a deployed instance as if it had been upgraded to a new contract class. The helper produces a coherent set of state overrides covering both the contract instance (bumped currentContractClassId) and the ContractInstanceRegistry's delayed-public- mutable storage (rewritten so the AVM's UpdateCheck resolves to the new class). The new class must already be registered on chain. Also adds the minimum plumbing to the StateOverrides type and PublicContractsDB to support contract-instance overrides; contract-class overrides are introduced separately upstack.
1 parent 43d10a4 commit 7e12172

14 files changed

Lines changed: 274 additions & 16 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` 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.
93+
94+
```typescript
95+
import { fastForwardContractUpdate } from '@aztec/aztec.js';
96+
97+
const stateOverrides = await fastForwardContractUpdate({
98+
instanceAddress: contract.address,
99+
newClassId: upgradedClass.id,
100+
node,
101+
});
102+
103+
const result = await contract.methods.upgraded_method().simulate({ stateOverrides });
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
@@ -23,6 +23,19 @@ 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:
27+
28+
```typescript
29+
import { fastForwardContractUpdate } from '@aztec/aztec.js';
30+
31+
const stateOverrides = await fastForwardContractUpdate({
32+
instanceAddress: contract.address,
33+
newClassId: upgradedClass.id,
34+
node,
35+
});
36+
const result = await contract.methods.upgraded_method().simulate({ stateOverrides });
37+
```
38+
2639
### [PXE] `proveTx` takes an options bag
2740

2841
`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: 4 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,9 @@ 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+
contractsDB.addContracts(stateOverrides?.contractInstances);
1554+
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config, contractsDB);
15531555

15541556
// REFACTOR: Consider merging ProcessReturnValues into ProcessedTx
15551557
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 stateOverrides 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+
stateOverrides: { publicStorage: [{ contract: contractAddress, slot: new Fr(1), value: new Fr(42) }] },
240+
}),
241+
).rejects.toThrow(/stateOverrides are 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ 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.');
137+
}
132138
const call = await this.getFunctionCall();
133139
const scopes = [...(options.additionalScopes ?? [])];
134140
const utilityResult = await this.wallet.executeUtility(call, {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
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);
56+
57+
const expectedSlots = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
58+
expect(overrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
59+
for (const entry of overrides.publicStorage!) {
60+
expect(entry.contract).toEqual(ProtocolContractAddress.ContractInstanceRegistry);
61+
}
62+
const baseSlot = expectedSlots.delayedPublicMutableSlot;
63+
expect(overrides.publicStorage![0].slot).toEqual(baseSlot);
64+
expect(overrides.publicStorage![overrides.publicStorage!.length - 1].slot).toEqual(
65+
expectedSlots.delayedPublicMutableHashSlot,
66+
);
67+
});
68+
69+
it('throws when the instance is not deployed', async () => {
70+
node.getContract.mockResolvedValue(undefined);
71+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not deployed/);
72+
});
73+
74+
it('throws when the new class is not registered', async () => {
75+
node.getContractClass.mockResolvedValue(undefined);
76+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not registered/);
77+
});
78+
79+
it('throws when the instance is already on the target class', async () => {
80+
const sameClassInstance = (
81+
await SerializableContractInstance.random({
82+
currentContractClassId: newClassId,
83+
originalContractClassId: originalClassId,
84+
})
85+
).withAddress(instanceAddress);
86+
node.getContract.mockResolvedValue(sameClassInstance);
87+
88+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/already on class/);
89+
});
90+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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, StateOverrides } from '@aztec/stdlib/interfaces/client';
10+
11+
/**
12+
* Builds `StateOverrides` that simulate a contract instance having already been upgraded to a new contract class.
13+
*
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+
*
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+
*
21+
* @param args.instanceAddress - Address of the deployed instance to upgrade.
22+
* @param args.newClassId - ID of the (already-registered) class to upgrade to.
23+
* @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.
26+
*/
27+
export async function fastForwardContractUpdate(args: {
28+
instanceAddress: AztecAddress;
29+
newClassId: Fr;
30+
node: AztecNode;
31+
}): Promise<StateOverrides> {
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(`Contract class ${newClassId} is not registered; register it before fast-forwarding to it`);
42+
}
43+
44+
if (instance.currentContractClassId.equals(newClassId)) {
45+
throw new Error(`Instance ${instanceAddress} is already on class ${newClassId}; nothing to fast-forward`);
46+
}
47+
48+
// Build the SVC the same way `ContractInstanceRegistry::update` would have, but with a timestamp_of_change
49+
// safely in the past so the AVM's UpdateCheck resolves to the post-upgrade class id at any sim timestamp.
50+
// `pre = 0` is the canonical "first upgrade" form: the C++ check falls back to `originalContractClassId`.
51+
const svc = new ScheduledValueChange([new Fr(0)], [newClassId], 1n);
52+
const sdc = ScheduledDelayChange.empty();
53+
const dpmv = new DelayedPublicMutableValuesWithHash(svc, sdc);
54+
55+
const { delayedPublicMutableSlot } = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
56+
const fields = await dpmv.toFields();
57+
58+
const publicStorage = fields.map((value, i) => ({
59+
contract: ProtocolContractAddress.ContractInstanceRegistry,
60+
slot: delayedPublicMutableSlot.add(new Fr(i)),
61+
value,
62+
}));
63+
64+
const upgradedInstance = { ...instance, currentContractClassId: newClassId };
65+
66+
return {
67+
publicStorage,
68+
contractInstances: [upgradedInstance],
69+
};
70+
}

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 override, the AVM dispatches against UpdatedContract's
196+
// bytecode and the call simulates successfully.
197+
const stateOverrides = 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, stateOverrides }),
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)