Skip to content

Commit 85cfaae

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 returns a coherent set of overrides covering both: - `stateOverrides` — public-storage writes to the ContractInstanceRegistry's delayed-public-mutable storage, so the AVM's UpdateCheck resolves to the new class id. - `contractOverrides` — an instance entry whose `currentContractClassId` is bumped to the new class. Plumbs `contractOverrides` as a top-level option on `SimulateInteractionOptions` through wallet → PXE → `AztecNode.simulatePublicCalls`. The new class must already be registered on chain.
1 parent ce67508 commit 85cfaae

20 files changed

Lines changed: 329 additions & 30 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 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
@@ -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 separate top-level option, `contractOverrides`, 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 both `stateOverrides` and `contractOverrides`:
27+
28+
```typescript
29+
import { fastForwardContractUpdate } from '@aztec/aztec.js';
30+
31+
const overrides = await fastForwardContractUpdate({
32+
instanceAddress: contract.address,
33+
newClassId: upgradedClass.id,
34+
node,
35+
});
36+
const result = await contract.methods.upgraded_method().simulate({ ...overrides });
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: 7 additions & 3 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,
@@ -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 applied to the ephemeral fork before simulation.
1488+
* @param contractOverrides - Optional contract instance overrides applied to the contract DB.
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;
@@ -1549,7 +1551,9 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
15491551
maxDebugLogMemoryReads: this.config.rpcSimulatePublicMaxDebugLogMemoryReads,
15501552
}),
15511553
});
1552-
const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config);
1554+
const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings());
1555+
contractsDB.addContracts(contractOverrides);
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 stateOverrides or contractOverrides 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(/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.stateOverrides?.publicStorage?.length || options.contractOverrides?.length) {
133+
throw new Error('stateOverrides and contractOverrides 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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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 { stateOverrides, contractOverrides } = await fastForwardContractUpdate({
51+
instanceAddress,
52+
newClassId,
53+
node,
54+
});
55+
56+
expect(contractOverrides).toHaveLength(1);
57+
expect(contractOverrides[0].address).toEqual(instanceAddress);
58+
expect(contractOverrides[0].currentContractClassId).toEqual(newClassId);
59+
expect(contractOverrides[0].originalContractClassId).toEqual(originalClassId);
60+
61+
const expectedSlots = await DelayedPublicMutableValuesWithHash.getContractUpdateSlots(instanceAddress);
62+
expect(stateOverrides.publicStorage).toHaveLength(DELAYED_PUBLIC_MUTABLE_VALUES_LEN + 1);
63+
for (const entry of stateOverrides.publicStorage!) {
64+
expect(entry.contract).toEqual(ProtocolContractAddress.ContractInstanceRegistry);
65+
}
66+
const baseSlot = expectedSlots.delayedPublicMutableSlot;
67+
expect(stateOverrides.publicStorage![0].slot).toEqual(baseSlot);
68+
expect(stateOverrides.publicStorage![stateOverrides.publicStorage!.length - 1].slot).toEqual(
69+
expectedSlots.delayedPublicMutableHashSlot,
70+
);
71+
});
72+
73+
it('throws when the instance is not deployed', async () => {
74+
node.getContract.mockResolvedValue(undefined);
75+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not deployed/);
76+
});
77+
78+
it('throws when the new class is not registered', async () => {
79+
node.getContractClass.mockResolvedValue(undefined);
80+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/not registered/);
81+
});
82+
83+
it('throws when the instance is already on the target class', async () => {
84+
const sameClassInstance = (
85+
await SerializableContractInstance.random({
86+
currentContractClassId: newClassId,
87+
originalContractClassId: originalClassId,
88+
})
89+
).withAddress(instanceAddress);
90+
node.getContract.mockResolvedValue(sameClassInstance);
91+
92+
await expect(fastForwardContractUpdate({ instanceAddress, newClassId, node })).rejects.toThrow(/already on class/);
93+
});
94+
});
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 type { ContractInstanceWithAddress } from '@aztec/stdlib/contract';
5+
import {
6+
DelayedPublicMutableValuesWithHash,
7+
ScheduledDelayChange,
8+
ScheduledValueChange,
9+
} from '@aztec/stdlib/delayed-public-mutable';
10+
import type { AztecNode, StateOverrides } from '@aztec/stdlib/interfaces/client';
11+
12+
/**
13+
* Builds the override blobs that simulate a contract instance having already been upgraded to a new contract class.
14+
*
15+
* Mirrors a real on-chain upgrade flow (`pxe.updateContract` followed by waiting out the delay). Returns:
16+
*
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.
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 `{ stateOverrides, contractOverrides }` to spread into a `simulate(...)` call.
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<{ stateOverrides: StateOverrides; contractOverrides: ContractInstanceWithAddress[] }> {
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: ContractInstanceWithAddress = { ...instance, currentContractClassId: newClassId };
67+
68+
return {
69+
stateOverrides: { publicStorage },
70+
contractOverrides: [upgradedInstance],
71+
};
72+
}

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

Lines changed: 7 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,13 @@ 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.
167+
*/
168+
contractOverrides?: ContractInstanceWithAddress[];
163169
};
164170

165171
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export const SimulateOptionsSchema = z.object({
337337
includeMetadata: optional(z.boolean()),
338338
additionalScopes: optional(z.array(schemas.AztecAddress)),
339339
stateOverrides: optional(StateOverridesSchema),
340+
contractOverrides: optional(z.array(ContractInstanceWithAddressSchema)),
340341
});
341342

342343
export const ProfileOptionsSchema = SimulateOptionsSchema.extend({

0 commit comments

Comments
 (0)