Skip to content

Commit 644467b

Browse files
committed
feat: contractOverrides for private-call simulation
Extends `contractOverrides` (introduced downstack) to also drive PXE-side ACIR dispatch, so private function calls on an overridden instance simulate against the override's class instead of the deployed one. - Translates aztec.js's `contractOverrides: ContractInstanceWithAddress[]` into PXE's existing `SimulationOverrides.contracts` record at the wallet boundary. - Relaxes the PXE-internal `ContractOverrides` type so `artifact` is optional; `proxied_contract_data_source` falls through to the regular `ContractStore` when the override has no artifact. - Exposes `wallet.registerContractClass(artifact)` so users can register the new class artifact locally before the override is used.
1 parent 043c852 commit 644467b

9 files changed

Lines changed: 114 additions & 21 deletions

File tree

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

Lines changed: 20 additions & 6 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`: an array of `ContractInstanceWithAddress` to override deployed contract instances. Register the new class artifact locally first via `wallet.registerContractClass(artifact)`.
7376

7477
Override a public-storage slot:
7578

@@ -81,26 +84,37 @@ 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 `stateOverrides` (registry storage rewrites) and `contractOverrides` (instance with bumped class id). A single spread covers any mix of private and public function calls on the upgraded contract.
99+
100+
Register the new class artifact in your local PXE first via `wallet.registerContractClass(artifact)`.
93101

94102
```typescript
95103
import { fastForwardContractUpdate } from '@aztec/aztec.js';
104+
import { getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
105+
106+
// One-time local PXE registration of the new class artifact
107+
await wallet.registerContractClass(UpdatedContract.artifact);
96108

109+
const newClassId = (await getContractClassFromArtifact(UpdatedContract.artifact)).id;
97110
const overrides = await fastForwardContractUpdate({
98111
instanceAddress: contract.address,
99-
newClassId: upgradedClass.id,
112+
newClassId,
100113
node,
101114
});
102115

103-
const result = await contract.methods.upgraded_method().simulate({ ...overrides });
116+
const upgradedContract = UpdatedContract.at(contract.address, wallet);
117+
const result = await upgradedContract.methods.upgraded_method().simulate({ ...overrides });
104118
```
105119

106120
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: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,23 @@ 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`:
26+
`.simulate(...)` also accepts `contractOverrides` - an array of `ContractInstanceWithAddress` that override deployed instances at their addresses. Applied on both the AVM (public dispatch) and PXE (private dispatch via ACIR) sides. Register the new class artifact locally first via `wallet.registerContractClass(artifact)` (newly exposed for this purpose) so PXE has the ACIR.
27+
28+
To simulate a complete on-chain upgrade flow, use the `fastForwardContractUpdate` helper. It returns both `stateOverrides` (registry storage rewrites) and `contractOverrides` (instance with bumped class id). Register the new artifact locally via `wallet.registerContractClass(artifact)` first:
2729

2830
```typescript
29-
import { fastForwardContractUpdate } from '@aztec/aztec.js';
31+
import { fastForwardContractUpdate, getContractClassFromArtifact } from '@aztec/aztec.js/contracts';
32+
33+
await wallet.registerContractClass(UpdatedContract.artifact);
3034

35+
const newClassId = (await getContractClassFromArtifact(UpdatedContract.artifact)).id;
3136
const overrides = await fastForwardContractUpdate({
3237
instanceAddress: contract.address,
33-
newClassId: upgradedClass.id,
38+
newClassId,
3439
node,
3540
});
36-
const result = await contract.methods.upgraded_method().simulate({ ...overrides });
41+
const upgradedContract = UpdatedContract.at(contract.address, wallet);
42+
const result = await upgradedContract.methods.upgraded_method().simulate({ ...overrides });
3743
```
3844

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

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ describe('WalletSchema', () => {
155155
});
156156
});
157157

158+
it('registerContractClass', async () => {
159+
const mockArtifact: ContractArtifact = {
160+
name: 'TestContract',
161+
functions: [],
162+
nonDispatchPublicFunctions: [],
163+
outputs: { structs: {}, globals: {} },
164+
fileMap: {},
165+
storageLayout: {},
166+
};
167+
await context.client.registerContractClass(mockArtifact);
168+
});
169+
158170
it('simulateTx', async () => {
159171
const exec: ExecutionPayload = {
160172
calls: [],
@@ -448,6 +460,8 @@ class MockWallet implements Wallet {
448460
};
449461
}
450462

463+
async registerContractClass(_artifact: any): Promise<void> {}
464+
451465
async simulateTx(_exec: ExecutionPayload, _opts: SimulateOptions): Promise<TxSimulationResultWithAppOffset> {
452466
return TxSimulationResultWithAppOffset.fromResultAndOffset(await TxSimulationResult.random(), 0);
453467
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ export type Wallet = {
272272
artifact?: ContractArtifact,
273273
secretKey?: Fr,
274274
): Promise<ContractInstanceWithAddress>;
275+
/**
276+
* Registers a contract class artifact in the local PXE without binding it to any instance.
277+
* Useful for simulation flows that need the artifact available locally before any on-chain
278+
* upgrade has taken effect. The artifact's class id must match its content; no chain check.
279+
*/
280+
registerContractClass(artifact: ContractArtifact): Promise<void>;
275281
simulateTx(exec: ExecutionPayload, opts: SimulateOptions): Promise<TxSimulationResultWithAppOffset>;
276282
executeUtility(call: FunctionCall, opts: ExecuteUtilityOptions): Promise<UtilityExecutionResult>;
277283
profileTx(exec: ExecutionPayload, opts: ProfileOptions): Promise<TxProfileResult>;
@@ -560,6 +566,7 @@ const WalletMethodSchemas = {
560566
.function()
561567
.args(ContractInstanceWithAddressSchema, optional(ContractArtifactSchema), optional(schemas.Fr))
562568
.returns(ContractInstanceWithAddressSchema),
569+
registerContractClass: z.function().args(ContractArtifactSchema).returns(z.void()),
563570
simulateTx: z
564571
.function()
565572
.args(ExecutionPayloadSchema, SimulateOptionsSchema)

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,29 @@ describe('e2e_contract_updates', () => {
208208
contract.methods.set_public_value(5678n).simulate({ from: defaultAccountAddress }),
209209
).resolves.toBeDefined();
210210
});
211+
212+
// UpdatedContract.set_private_value is a private function that doesn't exist on UpdatableContract.
213+
// For PXE-side ACIR dispatch to find it, the artifact must be registered locally first via
214+
// wallet.registerContractClass; the helper itself only takes the class id.
215+
it('fastForwardContractUpdate enables simulation of post-upgrade private calls', async () => {
216+
const updatedContract = UpdatedContract.at(contract.address, wallet);
217+
218+
// Without overrides (and without local artifact registration), the new private function isn't
219+
// available on the deployed class.
220+
await expect(
221+
updatedContract.methods.set_private_value().simulate({ from: defaultAccountAddress }),
222+
).rejects.toThrow();
223+
224+
// Register the new artifact in the local PXE so the ACIR simulator can find its private functions.
225+
await wallet.registerContractClass(UpdatedContract.artifact);
226+
227+
const overrides = await fastForwardContractUpdate({
228+
instanceAddress: contract.address,
229+
newClassId: updatedContractClassId,
230+
node: aztecNode,
231+
});
232+
await expect(
233+
updatedContract.methods.set_private_value().simulate({ from: defaultAccountAddress, ...overrides }),
234+
).resolves.toBeDefined();
235+
});
211236
});

yarn-project/end-to-end/src/test-wallet/worker_wallet.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export class WorkerWallet implements Wallet {
166166
return this.call('registerContract', instance, artifact, secretKey);
167167
}
168168

169+
registerContractClass(artifact: ContractArtifact): Promise<void> {
170+
return this.call('registerContractClass', artifact);
171+
}
172+
169173
simulateTx(exec: ExecutionPayload, opts: SimulateOptions): Promise<TxSimulationResultWithAppOffset> {
170174
return this.call('simulateTx', exec, opts);
171175
}

yarn-project/pxe/src/contract_function_simulator/proxied_contract_data_source.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ export class ProxiedContractStoreFactory {
3838
}
3939
case 'getFunctionArtifact': {
4040
return async (contractAddress: AztecAddress, selector: FunctionSelector) => {
41-
if (overrides[contractAddress.toString()]) {
42-
const { artifact } = overrides[contractAddress.toString()]!;
43-
const functions = artifact.functions;
41+
const override = overrides[contractAddress.toString()];
42+
if (override?.artifact) {
43+
const functions = override.artifact.functions;
4444
for (let i = 0; i < functions.length; i++) {
4545
const fn = functions[i];
4646
const fnSelector = await FunctionSelector.fromNameAndParameters(fn.name, fn.parameters);
@@ -58,9 +58,9 @@ export class ProxiedContractStoreFactory {
5858
}
5959
case 'getFunctionArtifactWithDebugMetadata': {
6060
return async (contractAddress: AztecAddress, selector: FunctionSelector) => {
61-
if (overrides[contractAddress.toString()]) {
62-
const { artifact } = overrides[contractAddress.toString()]!;
63-
const functions = artifact.functions;
61+
const override = overrides[contractAddress.toString()];
62+
if (override?.artifact) {
63+
const functions = override.artifact.functions;
6464
for (let i = 0; i < functions.length; i++) {
6565
const fn = functions[i];
6666
const fnSelector = await FunctionSelector.fromNameAndParameters(fn.name, fn.parameters);

yarn-project/stdlib/src/tx/simulated_tx.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,28 @@ import { NestedProcessReturnValues, PublicSimulationOutput } from './public_simu
2424
import { Tx } from './tx.js';
2525

2626
/*
27-
* If passed during the execution of a user circuit, the contract function simulator will replace the instance and class
28-
* of the contract with the one provided in the overrides for that address. An example use case
29-
* would be overriding your own account contract so that valid signatures don't have to be provided while simulating.
27+
* Per-address contract DB overrides applied during simulation. For each address, replaces the
28+
* (instance, optional artifact) pair the simulator uses for that address:
29+
*
30+
* - `instance` is always applied. The AVM-side PublicContractsDB uses it for public-call dispatch;
31+
* the PXE-side ACIR simulator uses it to resolve address → class id.
32+
* - `artifact` is optional. When present, the PXE-side simulator uses the override artifact for
33+
* ACIR/function lookups during private execution. When absent, PXE falls back to whatever artifact
34+
* is registered for the override's `instance.currentContractClassId`.
35+
*
36+
* Common use cases: simulating an upgraded class without scheduling a real upgrade; overriding your
37+
* own account contract so signatures don't need to be valid during simulation.
3038
*/
3139
export type ContractOverrides = Record<
3240
string /* AztecAddress as string */,
33-
{ instance: ContractInstanceWithAddress; artifact: ContractArtifact }
41+
{ instance: ContractInstanceWithAddress; artifact?: ContractArtifact }
3442
>;
3543

44+
export const ContractOverridesSchema = z.record(
45+
z.string(),
46+
z.object({ instance: ContractInstanceWithAddressSchema, artifact: ContractArtifactSchema.optional() }),
47+
);
48+
3649
/*
3750
* Optional values that can be overridden during simulation. In order to simulate a transaction with these
3851
* set, it *must* be run without the kernel circuits, or validations will fail
@@ -50,7 +63,7 @@ export class SimulationOverrides {
5063
contracts: optional(
5164
z.record(
5265
z.string(),
53-
z.object({ instance: ContractInstanceWithAddressSchema, artifact: ContractArtifactSchema }),
66+
z.object({ instance: ContractInstanceWithAddressSchema, artifact: ContractArtifactSchema.optional() }),
5467
),
5568
),
5669
})

yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client';
5757
import {
5858
BlockHeader,
5959
ExecutionPayload,
60+
SimulationOverrides,
6061
type TxExecutionRequest,
6162
type TxProfileResult,
6263
type UtilityExecutionResult,
@@ -354,6 +355,10 @@ export abstract class BaseWallet implements Wallet {
354355
return instance;
355356
}
356357

358+
registerContractClass(artifact: ContractArtifact): Promise<void> {
359+
return this.pxe.registerContractClass(artifact);
360+
}
361+
357362
/**
358363
* Simulates calls through the standard PXE path (account entrypoint).
359364
* @param executionPayload - The execution payload to simulate.
@@ -373,6 +378,11 @@ export abstract class BaseWallet implements Wallet {
373378
senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs),
374379
stateOverrides: opts.stateOverrides,
375380
contractOverrides: opts.contractOverrides,
381+
overrides: opts.contractOverrides?.length
382+
? new SimulationOverrides(
383+
Object.fromEntries(opts.contractOverrides.map(instance => [instance.address.toString(), { instance }])),
384+
)
385+
: undefined,
376386
});
377387
const appCallOffset = await this.computeAppCallOffset(opts.from, opts.feeOptions);
378388
return TxSimulationResultWithAppOffset.fromResultAndOffset(result, appCallOffset);

0 commit comments

Comments
 (0)