Skip to content

Commit 0fadda7

Browse files
authored
Merge branch 'backport-to-v4-next-staging' into claudebox/drop-compat-e2e-flaky-tests
2 parents 77a71cd + 80fe2ec commit 0fadda7

8 files changed

Lines changed: 167 additions & 87 deletions

File tree

.github/workflows/ci3.yml

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,6 @@ jobs:
298298
# Escape hatch: ci-skip-compat-e2e label makes failures non-blocking on release PRs.
299299
ci-compat-e2e:
300300
runs-on: ubuntu-latest
301-
permissions:
302-
id-token: write
303-
contents: read
304301
needs: [ci]
305302
if: |
306303
always()
@@ -320,17 +317,11 @@ jobs:
320317
with:
321318
ref: ${{ github.event.pull_request.head.sha || github.sha }}
322319

323-
- name: Configure AWS credentials (OIDC)
324-
uses: aws-actions/configure-aws-credentials@v4
325-
with:
326-
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
327-
aws-region: us-east-2
328-
role-session-name: ci3-compat-e2e-${{ github.run_id }}
329-
role-duration-seconds: 21600 # 6h – covers AWS_SHUTDOWN_TIME (300 min) + 60 min buffer
330-
331320
- name: Run Backwards Compatibility E2E Tests
332321
timeout-minutes: 330
333322
env:
323+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
324+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
334325
GITHUB_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }}
335326
BUILD_INSTANCE_SSH_KEY: ${{ secrets.BUILD_INSTANCE_SSH_KEY }}
336327
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}

aztec-up/test/aztec-cli-acceptance-test/aztec-cli-acceptance-test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,14 @@ if (result.ok) {
7272
log(`All steps PASSED (${msToSecs(Date.now() - totalStart)}s total)`);
7373
console.log(`TEST_RESULT=pass version=${result.aztecVersion}`);
7474
rmSync(TMP_DIR, { recursive: true, force: true });
75+
// Explicit exit fires the 'exit' handler registered in startLocalNetwork(), which SIGTERMs the
76+
// long-running `aztec start --local-network` child. Without this, the child keeps Node's event
77+
// loop alive — the handler never fires and the process hangs until the CI timeout cancels it.
78+
process.exit(0);
7579
} else {
7680
reportFailure(result.stepName, result.aztecVersion, result.error);
7781
leaveTmpDirForInspection();
78-
process.exitCode = 1;
82+
process.exit(1);
7983
}
8084

8185
async function main(): Promise<RunResult> {

noir-projects/aztec-nr/aztec/src/history/mod.nr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ pub mod deployment;
1414
pub mod note;
1515
pub mod nullifier;
1616
pub mod storage;
17-
mod test;
17+
pub(crate) mod test;

noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ unconstrained fn get_block_hash_membership_witness_oracle(
2121

2222
/// Returns a membership witness for a `note_hash` in the note hash tree whose root is defined in
2323
// `anchor_block_header`.
24-
// TODO(https://linear.app/aztec-labs/issue/F-652): add Noir tests for this oracle
2524
pub unconstrained fn get_note_hash_membership_witness(
2625
anchor_block_header: BlockHeader,
2726
note_hash: Field,
@@ -55,10 +54,52 @@ pub unconstrained fn get_maybe_block_hash_membership_witness(
5554
}
5655

5756
mod test {
57+
use crate::history::test::{create_note, NOTE_CREATED_AT};
58+
use crate::note::note_interface::NoteHash;
5859
use crate::oracle::block_header::get_block_header_at;
59-
use crate::protocol::{merkle_tree::root::root_from_sibling_path, traits::Hash};
60-
use crate::test::helpers::test_environment::TestEnvironment;
61-
use super::{get_block_hash_membership_witness, get_maybe_block_hash_membership_witness};
60+
use crate::protocol::{
61+
hash::{compute_siloed_note_hash, compute_unique_note_hash},
62+
merkle_tree::root::root_from_sibling_path,
63+
traits::Hash,
64+
};
65+
use crate::test::helpers::test_environment::{PrivateContextOptions, TestEnvironment};
66+
use super::{
67+
get_block_hash_membership_witness, get_maybe_block_hash_membership_witness, get_note_hash_membership_witness,
68+
};
69+
70+
#[test]
71+
unconstrained fn get_note_hash_membership_witness_returns_valid_witness_for_known_note() {
72+
let (env, hinted_note) = create_note();
73+
74+
env.private_context_opts(PrivateContextOptions::new().at_anchor_block_number(NOTE_CREATED_AT), |context| {
75+
let anchor = context.anchor_block_header;
76+
77+
let note_hash =
78+
hinted_note.note.compute_note_hash(hinted_note.owner, hinted_note.storage_slot, hinted_note.randomness);
79+
let siloed = compute_siloed_note_hash(hinted_note.contract_address, note_hash);
80+
let unique = compute_unique_note_hash(hinted_note.metadata.to_settled().note_nonce(), siloed);
81+
82+
let witness = get_note_hash_membership_witness(anchor, unique);
83+
84+
assert_eq(
85+
root_from_sibling_path(unique, witness.leaf_index, witness.sibling_path),
86+
anchor.state.partial.note_hash_tree.root,
87+
);
88+
});
89+
}
90+
91+
#[test(should_fail_with = "not found in the note hash tree at block")]
92+
unconstrained fn get_note_hash_membership_witness_panics_for_unknown_note() {
93+
let env = TestEnvironment::new();
94+
95+
env.mine_block();
96+
env.mine_block();
97+
98+
env.private_context(|context| {
99+
let anchor = context.anchor_block_header;
100+
let _witness = get_note_hash_membership_witness(anchor, 0xdeadbeef);
101+
});
102+
}
62103

63104
#[test]
64105
unconstrained fn get_block_hash_membership_witness_returns_valid_witness_for_known_block() {

yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import { Matcher, type MatcherCreator, type MockProxy, mock } from 'jest-mock-ex
6666
import { toFunctionSelector } from 'viem';
6767

6868
import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js';
69-
import { syncState } from '../../contract_sync/helpers.js';
69+
import { syncScope } from '../../contract_sync/helpers.js';
7070
import type { MessageContextService } from '../../messages/message_context_service.js';
7171
import type { AddressStore } from '../../storage/address_store/address_store.js';
7272
import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js';
@@ -326,19 +326,9 @@ describe('Private Execution test suite', () => {
326326
messageContextService.getMessageContextsByTxHash.mockResolvedValue([]);
327327
// Configure mock to actually perform sync_state calls (needed for nested call tests)
328328
contractSyncService.ensureContractSynced.mockImplementation(
329-
async (contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, jobId, scopes) => {
329+
async (contractAddress, functionToInvokeAfterSync, utilityExecutor, _anchorBlockHeader, _jobId, scopes) => {
330330
for (const scope of scopes) {
331-
await syncState(
332-
contractAddress,
333-
contractStore,
334-
functionToInvokeAfterSync,
335-
utilityExecutor,
336-
noteStore,
337-
aztecNode,
338-
anchorBlockHeader,
339-
jobId,
340-
scope,
341-
);
331+
await syncScope(contractAddress, contractStore, functionToInvokeAfterSync, utilityExecutor, scope);
342332
}
343333
},
344334
);

yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,61 @@ describe('ContractSyncService', () => {
272272
});
273273
});
274274

275+
describe('multi-scope sync batching', () => {
276+
it('batches nullifier sync across all unsynced scopes', async () => {
277+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
278+
scopeA,
279+
scopeB,
280+
]);
281+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
282+
expect(noteStore.getNotes).toHaveBeenCalledWith(
283+
expect.objectContaining({ contractAddress, scopes: [scopeA, scopeB] }),
284+
jobId,
285+
);
286+
});
287+
288+
it('only includes unsynced scopes in nullifier sync', async () => {
289+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
290+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
291+
expect(noteStore.getNotes).toHaveBeenCalledWith(
292+
expect.objectContaining({ contractAddress, scopes: [scopeA] }),
293+
jobId,
294+
);
295+
296+
noteStore.getNotes.mockClear();
297+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
298+
scopeA,
299+
scopeB,
300+
]);
301+
// scopeA is already cached, so nullifier sync only runs for scopeB
302+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
303+
expect(noteStore.getNotes).toHaveBeenCalledWith(
304+
expect.objectContaining({ contractAddress, scopes: [scopeB] }),
305+
jobId,
306+
);
307+
});
308+
309+
it('re-runs nullifier sync after scope invalidation', async () => {
310+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
311+
scopeA,
312+
scopeB,
313+
]);
314+
noteStore.getNotes.mockClear();
315+
316+
service.invalidateContractForScopes(contractAddress, [scopeA]);
317+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
318+
scopeA,
319+
scopeB,
320+
]);
321+
// Only scopeA was invalidated, so nullifier sync runs for just scopeA
322+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
323+
expect(noteStore.getNotes).toHaveBeenCalledWith(
324+
expect.objectContaining({ contractAddress, scopes: [scopeA] }),
325+
jobId,
326+
);
327+
});
328+
});
329+
275330
describe('invalidateContractForScopes', () => {
276331
const contract2 = AztecAddress.fromBigInt(300n);
277332

yarn-project/pxe/src/contract_sync/contract_sync_service.ts

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { Logger } from '@aztec/foundation/log';
22
import { Semaphore } from '@aztec/foundation/queue';
3+
import { isProtocolContract } from '@aztec/protocol-contracts';
34
import type { FunctionCall, FunctionSelector } from '@aztec/stdlib/abi';
45
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
56
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
67
import type { BlockHeader } from '@aztec/stdlib/tx';
78

89
import type { StagedStore } from '../job_coordinator/job_coordinator.js';
10+
import { NoteService } from '../notes/note_service.js';
911
import type { ContractStore } from '../storage/contract_store/contract_store.js';
1012
import type { NoteStore } from '../storage/note_store/note_store.js';
11-
import { syncState, verifyCurrentClassId } from './helpers.js';
13+
import { syncScope, verifyCurrentClassId } from './helpers.js';
1214

1315
/** Maximum number of scope syncs running concurrently across the PXE. */
1416
const MAX_CONCURRENT_SCOPE_SYNCS = 5;
@@ -29,7 +31,7 @@ export class ContractSyncService implements StagedStore {
2931

3032
// Tracks class ID verification per contract. Keyed by contract address only (no scope), since
3133
// class ID verification is scope-independent. Cleared on wipe/discard.
32-
private verifiedClassIds: Map<string, Promise<void>> = new Map();
34+
private classIdVerificationCache: Map<string, Promise<void>> = new Map();
3335

3436
// Per-job excluded contract addresses - these contracts should not be synced.
3537
private excludedFromSync: Map<string, Set<string>> = new Map();
@@ -72,22 +74,8 @@ export class ContractSyncService implements StagedStore {
7274
return;
7375
}
7476

75-
this.#startSyncIfNeeded(
76-
contractAddress,
77-
scopes,
78-
() => verifyCurrentClassId(contractAddress, this.aztecNode, this.contractStore, anchorBlockHeader),
79-
scope =>
80-
syncState(
81-
contractAddress,
82-
this.contractStore,
83-
functionToInvokeAfterSync,
84-
utilityExecutor,
85-
this.noteStore,
86-
this.aztecNode,
87-
anchorBlockHeader,
88-
jobId,
89-
scope,
90-
),
77+
this.#startSyncIfNeeded(contractAddress, scopes, anchorBlockHeader, jobId, scope =>
78+
syncScope(contractAddress, this.contractStore, functionToInvokeAfterSync, utilityExecutor, scope),
9179
);
9280

9381
await this.#awaitSync(contractAddress, scopes);
@@ -105,7 +93,7 @@ export class ContractSyncService implements StagedStore {
10593
wipe(): void {
10694
this.log.debug(`Wiping contract sync cache (${this.syncedContracts.size} entries)`);
10795
this.syncedContracts.clear();
108-
this.verifiedClassIds.clear();
96+
this.classIdVerificationCache.clear();
10997
}
11098

11199
commit(jobId: string): Promise<void> {
@@ -118,7 +106,7 @@ export class ContractSyncService implements StagedStore {
118106
// We clear the synced contracts cache here because, when the job is discarded, any associated database writes from
119107
// the sync are also undone.
120108
this.syncedContracts.clear();
121-
this.verifiedClassIds.clear();
109+
this.classIdVerificationCache.clear();
122110
this.excludedFromSync.delete(jobId);
123111
return Promise.resolve();
124112
}
@@ -128,14 +116,16 @@ export class ContractSyncService implements StagedStore {
128116
}
129117

130118
/**
131-
* If there are unsynced scopes, starts one sync per scope (bounded by #syncSlot) and stores each promise in the
132-
* cache with per-scope error cleanup. The verifyFn runs once for the whole fan-out and is awaited by every new
133-
* scope's promise, matching the pre-parallelization invariant that a cache-miss batch re-verifies the class id.
119+
* For each unsynced scope, creates a promise that waits on:
120+
* 1. Class ID verification (cached per contract, scope-independent).
121+
* 2. Note nullifier sync (shared, batched across all unsynced scopes).
122+
* 3. Per-scope sync (individual, semaphore-bounded).
134123
*/
135124
#startSyncIfNeeded(
136125
contractAddress: AztecAddress,
137126
scopes: AztecAddress[],
138-
verifyFn: () => Promise<void>,
127+
anchorBlockHeader: BlockHeader,
128+
jobId: string,
139129
syncScopeFn: (scope: AztecAddress) => Promise<void>,
140130
): void {
141131
const scopesToSync = scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope)));
@@ -144,11 +134,13 @@ export class ContractSyncService implements StagedStore {
144134
}
145135

146136
this.log.debug(`Syncing contract ${contractAddress} for ${scopesToSync.length} scope(s)`);
147-
const verifyPromise = this.#getOrStartVerification(contractAddress, verifyFn);
137+
138+
const verifyPromise = this.#verifyClassId(contractAddress, anchorBlockHeader);
139+
const syncNullifiersPromise = this.#syncNoteNullifiers(contractAddress, anchorBlockHeader, jobId, scopesToSync);
148140

149141
for (const scope of scopesToSync) {
150142
const key = toKey(contractAddress, scope);
151-
const promise = Promise.all([verifyPromise, this.#runBounded(() => syncScopeFn(scope))])
143+
const promise = Promise.all([verifyPromise, syncNullifiersPromise, this.#runBounded(() => syncScopeFn(scope))])
152144
.then(() => {})
153145
.catch(err => {
154146
this.syncedContracts.delete(key);
@@ -158,21 +150,40 @@ export class ContractSyncService implements StagedStore {
158150
}
159151
}
160152

161-
/** Returns the cached verification promise for a contract, starting a new one if needed. Evicts from cache on failure so retries re-verify. */
162-
#getOrStartVerification(contractAddress: AztecAddress, verifyFn: () => Promise<void>): Promise<void> {
153+
/** Verifies the local class ID matches the on-chain value (cached, evicts on failure so retries re-verify). */
154+
#verifyClassId(contractAddress: AztecAddress, anchorBlockHeader: BlockHeader): Promise<void> {
163155
const contractKey = contractAddress.toString();
164-
const cached = this.verifiedClassIds.get(contractKey);
156+
const cached = this.classIdVerificationCache.get(contractKey);
165157
if (cached) {
166158
return cached;
167159
}
168-
const promise = verifyFn().catch(err => {
169-
this.verifiedClassIds.delete(contractKey);
170-
throw err;
171-
});
172-
this.verifiedClassIds.set(contractKey, promise);
160+
const promise = verifyCurrentClassId(contractAddress, this.aztecNode, this.contractStore, anchorBlockHeader).catch(
161+
err => {
162+
this.classIdVerificationCache.delete(contractKey);
163+
throw err;
164+
},
165+
);
166+
this.classIdVerificationCache.set(contractKey, promise);
173167
return promise;
174168
}
175169

170+
/** Syncs note nullifiers across all unsynced scopes in a single batched call. */
171+
async #syncNoteNullifiers(
172+
contractAddress: AztecAddress,
173+
anchorBlockHeader: BlockHeader,
174+
jobId: string,
175+
scopes: AztecAddress[],
176+
): Promise<void> {
177+
// Protocol contracts don't have private state to sync
178+
if (isProtocolContract(contractAddress)) {
179+
return;
180+
}
181+
// This runs in parallel with per-scope sync (which also writes to the note store). That's safe because
182+
// the note store handles concurrent operations.
183+
const noteService = new NoteService(this.noteStore, this.aztecNode, anchorBlockHeader, jobId);
184+
await noteService.syncNoteNullifiers(contractAddress, scopes);
185+
}
186+
176187
/** Runs fn while holding a slot in #syncSlot, bounding total concurrent scope syncs. */
177188
async #runBounded<T>(fn: () => Promise<T>): Promise<T> {
178189
await this.#syncSlot.acquire();

0 commit comments

Comments
 (0)