Skip to content

Commit d71c0c2

Browse files
committed
feat: public storage overrides for simulation via SimulationOverrides
Extends `SimulationOverrides` with a `publicStorage` field, plumbed through `.simulate({ overrides })` on aztec.js → wallet → PXE → `AztecNode.simulatePublicCalls`. Each entry writes a `(contract, slot, value)` into the public-data tree of the ephemeral world-state fork before the tx runs; real chain state is untouched.
1 parent da4bd0d commit d71c0c2

17 files changed

Lines changed: 296 additions & 18 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ 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 overrides
71+
72+
`.simulate()` accepts an `overrides` option that injects values into the simulator's ephemeral world-state fork and contract DB before the call runs. The override is scoped to that single simulation and thrown away afterwards.
73+
74+
Override a public-storage slot:
75+
76+
```typescript
77+
const result = await contract.methods.read_balance(account).simulate({
78+
overrides: {
79+
publicStorage: [{ contract: contract.address, slot: BALANCE_SLOT, value: new Fr(1_000_000n) }],
80+
},
81+
});
82+
```
83+
84+
Use this to:
85+
86+
- Set up state preconditions without running a full setup transaction
87+
- Reproduce a bug from production by pinning storage to the values seen at a specific block
88+
- Test branches that depend on rare values without orchestrating the contract calls that produce them
89+
7090
## Further reading
7191

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

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ Aztec is in active development. Each version may introduce breaking changes that
2222
+ overrides = { contracts: { [addr.toString()]: { instance: { ...instance, currentContractClassId: stubClassId } } } };
2323
```
2424

25+
### [Aztec.js] `simulate` accepts `overrides` for testing "what if storage value was X?"
26+
27+
`Contract.methods.foo(...).simulate(...)` now accepts an `overrides` option that injects values into the simulator's ephemeral world-state fork and contract DB before the call runs. The supported field is `publicStorage`, which writes a `(contract, slot, value)` into the public-data tree as if a previous tx had set it. Overrides are thrown away after simulation completes.
28+
29+
```typescript
30+
const result = await contract.methods.read_balance(account).simulate({
31+
overrides: {
32+
publicStorage: [{ contract: contract.address, slot: BALANCE_SLOT, value: new Fr(1_000_000n) }],
33+
},
34+
});
35+
```
36+
37+
The same option flows through `wallet.simulateTx` and eventually to `simulatePublicCalls` RPC on `AztecNode`.
38+
2539
### [PXE] `proveTx` takes an options bag
2640

2741
`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:
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Fr } from '@aztec/foundation/curves/bn254';
2+
import { PublicDataWrite } from '@aztec/stdlib/avm';
3+
import { AztecAddress } from '@aztec/stdlib/aztec-address';
4+
import { computePublicDataTreeLeafSlot } from '@aztec/stdlib/hash';
5+
import type { MerkleTreeWriteOperations } from '@aztec/stdlib/interfaces/server';
6+
import { MerkleTreeId } from '@aztec/stdlib/trees';
7+
8+
import { type MockProxy, mock } from 'jest-mock-extended';
9+
10+
import { applyPublicDataOverrides } from './public_data_overrides.js';
11+
12+
describe('applyPublicDataOverrides', () => {
13+
let fork: MockProxy<MerkleTreeWriteOperations>;
14+
15+
beforeEach(() => {
16+
fork = mock<MerkleTreeWriteOperations>();
17+
fork.sequentialInsert.mockResolvedValue({ lowLeavesWitnesses: [], insertionWitnesses: [] } as any);
18+
});
19+
20+
it('does nothing when overrides is undefined', async () => {
21+
await applyPublicDataOverrides(fork, undefined);
22+
expect(fork.sequentialInsert).not.toHaveBeenCalled();
23+
});
24+
25+
it('does nothing when overrides is empty', async () => {
26+
await applyPublicDataOverrides(fork, []);
27+
expect(fork.sequentialInsert).not.toHaveBeenCalled();
28+
});
29+
30+
it('inserts a single override at the correct leaf slot', async () => {
31+
const contract = await AztecAddress.random();
32+
const slot = Fr.random();
33+
const value = Fr.random();
34+
35+
await applyPublicDataOverrides(fork, [{ contract, slot, value }]);
36+
37+
const expectedLeafSlot = await computePublicDataTreeLeafSlot(contract, slot);
38+
const expectedWrite = new PublicDataWrite(expectedLeafSlot, value);
39+
40+
expect(fork.sequentialInsert).toHaveBeenCalledTimes(1);
41+
expect(fork.sequentialInsert).toHaveBeenCalledWith(MerkleTreeId.PUBLIC_DATA_TREE, [expectedWrite.toBuffer()]);
42+
});
43+
44+
it('inserts multiple overrides in a single batch call', async () => {
45+
const contract = await AztecAddress.random();
46+
const overrides = [
47+
{ contract, slot: Fr.random(), value: Fr.random() },
48+
{ contract, slot: Fr.random(), value: Fr.random() },
49+
];
50+
51+
await applyPublicDataOverrides(fork, overrides);
52+
53+
const expectedWrites = await Promise.all(
54+
overrides.map(async o => {
55+
const leafSlot = await computePublicDataTreeLeafSlot(o.contract, o.slot);
56+
return new PublicDataWrite(leafSlot, o.value);
57+
}),
58+
);
59+
60+
expect(fork.sequentialInsert).toHaveBeenCalledTimes(1);
61+
expect(fork.sequentialInsert).toHaveBeenCalledWith(
62+
MerkleTreeId.PUBLIC_DATA_TREE,
63+
expectedWrites.map(w => w.toBuffer()),
64+
);
65+
});
66+
67+
it('passes duplicate (contract, slot) writes through — last write wins via tree semantics', async () => {
68+
const contract = await AztecAddress.random();
69+
const slot = Fr.random();
70+
const firstValue = Fr.random();
71+
const secondValue = Fr.random();
72+
73+
await applyPublicDataOverrides(fork, [
74+
{ contract, slot, value: firstValue },
75+
{ contract, slot, value: secondValue },
76+
]);
77+
78+
// Both writes are passed to sequentialInsert; the tree handles last-wins ordering.
79+
expect(fork.sequentialInsert).toHaveBeenCalledTimes(1);
80+
const [treeId, leaves] = fork.sequentialInsert.mock.calls[0] as [MerkleTreeId, Buffer[]];
81+
expect(treeId).toBe(MerkleTreeId.PUBLIC_DATA_TREE);
82+
expect(leaves).toHaveLength(2);
83+
});
84+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { PublicDataWrite } from '@aztec/stdlib/avm';
2+
import { computePublicDataTreeLeafSlot } from '@aztec/stdlib/hash';
3+
import type { PublicStorageOverride } from '@aztec/stdlib/interfaces/client';
4+
import type { MerkleTreeWriteOperations } from '@aztec/stdlib/interfaces/server';
5+
import { MerkleTreeId } from '@aztec/stdlib/trees';
6+
7+
/**
8+
* Injects public-state overrides into an ephemeral world-state fork before simulation.
9+
*
10+
* Each override is written via the same `sequentialInsert` path the public processor
11+
* uses during real transaction execution, so low-leaf updates and root coherence are
12+
* handled identically for both simulation and proof generation.
13+
*
14+
* The fork is ephemeral — these writes never reach the committed world state.
15+
*/
16+
export async function applyPublicDataOverrides(
17+
fork: MerkleTreeWriteOperations,
18+
publicStorage: PublicStorageOverride[] | undefined,
19+
): Promise<void> {
20+
if (!publicStorage?.length) {
21+
return;
22+
}
23+
24+
const writes = await Promise.all(
25+
publicStorage.map(async o => {
26+
const leafSlot = await computePublicDataTreeLeafSlot(o.contract, o.slot);
27+
return new PublicDataWrite(leafSlot, o.value);
28+
}),
29+
);
30+
31+
await fork.sequentialInsert(
32+
MerkleTreeId.PUBLIC_DATA_TREE,
33+
writes.map(w => w.toBuffer()),
34+
);
35+
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
type GlobalVariableBuilder as GlobalVariableBuilderInterface,
104104
type IndexedTxEffect,
105105
PublicSimulationOutput,
106+
type SimulationOverrides,
106107
Tx,
107108
type TxHash,
108109
TxReceipt,
@@ -143,6 +144,7 @@ import {
143144
} from './block_response_helpers.js';
144145
import { type AztecNodeConfig, createKeyStoreForValidator } from './config.js';
145146
import { NodeMetrics } from './node_metrics.js';
147+
import { applyPublicDataOverrides } from './public_data_overrides.js';
146148

147149
/**
148150
* The aztec node.
@@ -1481,11 +1483,17 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
14811483
/**
14821484
* Simulates the public part of a transaction with the current state.
14831485
* @param tx - The transaction to simulate.
1486+
* @param skipFeeEnforcement - If true, fee enforcement is skipped.
1487+
* @param overrides - Optional pre-simulation overrides applied to the ephemeral fork and contract DB.
14841488
**/
14851489
@trackSpan('AztecNodeService.simulatePublicCalls', (tx: Tx) => ({
14861490
[Attributes.TX_HASH]: tx.getTxHash().toString(),
14871491
}))
1488-
public async simulatePublicCalls(tx: Tx, skipFeeEnforcement = false): Promise<PublicSimulationOutput> {
1492+
public async simulatePublicCalls(
1493+
tx: Tx,
1494+
skipFeeEnforcement = false,
1495+
overrides?: SimulationOverrides,
1496+
): Promise<PublicSimulationOutput> {
14891497
// Check total gas limit for simulation
14901498
const gasSettings = tx.data.constants.txContext.gasSettings;
14911499
const txGasLimit = gasSettings.gasLimits.l2Gas;
@@ -1530,6 +1538,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
15301538
await this.worldStateSynchronizer.syncImmediate(latestBlockNumber);
15311539
const merkleTreeFork = await this.worldStateSynchronizer.fork();
15321540
try {
1541+
await applyPublicDataOverrides(merkleTreeFork, overrides?.publicStorage);
15331542
const config = PublicSimulatorConfig.from({
15341543
skipFeeEnforcement,
15351544
collectDebugLogs: true,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,7 @@ export { AccountManager } from '../wallet/account_manager.js';
7878

7979
export { TxSimulationResultWithAppOffset } from '../wallet/tx_simulation_result_with_app_offset.js';
8080

81+
export { type PublicStorageOverride, PublicStorageOverrideSchema } from '@aztec/stdlib/interfaces/client';
82+
export { SimulationOverrides } from '@aztec/stdlib/tx';
83+
8184
export { type DeployAccountOptions, DeployAccountMethod } from '../wallet/deploy_account_method.js';

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type Capsule,
88
OFFCHAIN_MESSAGE_IDENTIFIER,
99
type OffchainEffect,
10+
type SimulationOverrides,
1011
type SimulationStats,
1112
type TxHash,
1213
type TxReceipt,
@@ -157,6 +158,8 @@ export type SimulateInteractionOptions = Omit<SendInteractionOptions, 'fee'> & {
157158
/** Whether to include metadata such as performance statistics (e.g. timing information of the different circuits and oracles) and gas estimation
158159
* in the simulation result, in addition to the return value and offchain effects */
159160
includeMetadata?: boolean;
161+
/** Pre-simulation overrides applied to the ephemeral fork and contract DB (publicStorage writes, contract instance/artifact overrides). */
162+
overrides?: SimulationOverrides;
160163
};
161164

162165
/**

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { ExecutionPayload, InTx } from '@aztec/stdlib/tx';
1919
import {
2020
Capsule,
2121
HashedValues,
22+
SimulationOverrides,
2223
TxHash,
2324
TxProfileResult,
2425
TxReceipt,
@@ -335,6 +336,7 @@ export const SimulateOptionsSchema = z.object({
335336
skipFeeEnforcement: optional(z.boolean()),
336337
includeMetadata: optional(z.boolean()),
337338
additionalScopes: optional(z.array(schemas.AztecAddress)),
339+
overrides: optional(SimulationOverrides.schema),
338340
});
339341

340342
export const ProfileOptionsSchema = SimulateOptionsSchema.extend({

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { AztecAddress } from '@aztec/aztec.js/addresses';
22
import { BatchCall, type ContractInstanceWithAddress } from '@aztec/aztec.js/contracts';
33
import { Fr } from '@aztec/aztec.js/fields';
4+
import type { AztecNode } from '@aztec/aztec.js/node';
45
import { TxExecutionResult } from '@aztec/aztec.js/tx';
6+
import type { PublicStorageOverride } from '@aztec/aztec.js/wallet';
57
import type { Wallet } from '@aztec/aztec.js/wallet';
68
import { AvmInitializerTestContract } from '@aztec/noir-test-contracts.js/AvmInitializerTest';
79
import { AvmTestContract } from '@aztec/noir-test-contracts.js/AvmTest';
@@ -16,13 +18,15 @@ describe('e2e_avm_simulator', () => {
1618
jest.setTimeout(TIMEOUT);
1719

1820
let wallet: Wallet;
21+
let aztecNode: AztecNode;
1922
let defaultAccountAddress: AztecAddress;
2023
let teardown: () => Promise<void>;
2124

2225
beforeAll(async () => {
2326
({
2427
teardown,
2528
wallet,
29+
aztecNode,
2630
accounts: [defaultAccountAddress],
2731
} = await setup(1));
2832
await ensureAccountContractsPublished(wallet, [defaultAccountAddress]);
@@ -249,6 +253,51 @@ describe('e2e_avm_simulator', () => {
249253
});
250254
});
251255

256+
describe('publicDataOverrides', () => {
257+
// AvmTestContract: `single` is the first storage variable and lives at raw slot 1.
258+
const SINGLE_SLOT = new Fr(1n);
259+
let avmContract: AvmTestContract;
260+
261+
beforeEach(async () => {
262+
({ contract: avmContract } = await AvmTestContract.deploy(wallet).send({ from: defaultAccountAddress }));
263+
});
264+
265+
it('simulated read of an unwritten slot returns the override; real storage is untouched', async () => {
266+
const overrideValue = new Fr(0xdeadbeefn);
267+
const publicStorage: PublicStorageOverride[] = [
268+
{ contract: avmContract.address, slot: SINGLE_SLOT, value: overrideValue },
269+
];
270+
271+
const simResult = await avmContract.methods
272+
.read_storage_single()
273+
.simulate({ from: defaultAccountAddress, overrides: { publicStorage } });
274+
expect(simResult.result).toEqual(overrideValue.toBigInt());
275+
276+
// Real state is untouched — the slot was never written.
277+
const realValue = await aztecNode.getPublicStorageAt('latest', avmContract.address, SINGLE_SLOT);
278+
expect(realValue.toBigInt()).toEqual(0n);
279+
});
280+
281+
it('simulated read returns the override when a slot was previously written by a real tx', async () => {
282+
const realValue = new Fr(100n);
283+
await avmContract.methods.set_storage_single(realValue).send({ from: defaultAccountAddress });
284+
285+
const overrideValue = new Fr(999n);
286+
const publicStorage: PublicStorageOverride[] = [
287+
{ contract: avmContract.address, slot: SINGLE_SLOT, value: overrideValue },
288+
];
289+
290+
const simResult = await avmContract.methods
291+
.read_storage_single()
292+
.simulate({ from: defaultAccountAddress, overrides: { publicStorage } });
293+
expect(simResult.result).toEqual(overrideValue.toBigInt());
294+
295+
// Real storage still holds the original written value.
296+
const storedValue = await aztecNode.getPublicStorageAt('latest', avmContract.address, SINGLE_SLOT);
297+
expect(storedValue.toBigInt()).toEqual(realValue.toBigInt());
298+
});
299+
});
300+
252301
describe('AvmInitializerTestContract', () => {
253302
let avmContract: AvmInitializerTestContract;
254303

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,14 @@ export class TestWallet extends BaseWallet {
266266
: executionPayload;
267267
const chainInfo = await this.getChainInfo();
268268

269-
let overrides: SimulationOverrides | undefined;
269+
let overrides = opts.overrides;
270270
let txRequest: TxExecutionRequest;
271271
if (useOverride) {
272272
const accountOverrides = await this.buildAccountOverrides(scopes);
273-
overrides = new SimulationOverrides(accountOverrides);
273+
overrides = new SimulationOverrides({
274+
publicStorage: overrides?.publicStorage,
275+
contracts: { ...overrides?.contracts, ...accountOverrides },
276+
});
274277
}
275278

276279
if (from === NO_FROM) {

0 commit comments

Comments
 (0)