Skip to content

Commit 99632a7

Browse files
vezenovmaztec-bot
authored andcommitted
fix(pxe): widen tracked sender tagging ranges with onchain discovery evidence (#24655)
Fixes the sender tag sync conflict from https://gist.github.com/nventuro/0aa690736b1d2865e27197723a814e9d, including the "Window straddle" residual edge. Discovery could re-derive a different index range for an already tracked (secret, txHash) pair and hit the `Conflicting range` throw in `storePendingIndexes`, permanently wedging the secret. Two triggers: - A same-PXE tx partially reverts: the chain only shows the surviving non-revertible sub-range of the prove-time entry, and the throw fired before the finalized receipt step could resolve it. - A tx straddles a sync window boundary: the window loop assembles its range piecewise, so window 2 stores a different range than window 1 (latent regardless of reverts). Fix: discovery passes `mergeExisting` to `storePendingIndexes`, which widens the stored entry to the union of both ranges (grow-only, driven by onchain evidence). The finalized receipt step still resolves partial reverts. The only other writer of pending ranges, `persistSenderTaggingIndexRangesForTx` (records the indexes a tx sent from this PXE used, at prove time), does not pass the flag and still throws on a mismatch: there it indicates a bug, not partial onchain evidence. Red-green (all fail on the base with the `Conflicting range` throw): - partial revert repro: finalizes the surviving index, frees the squashed ones, repeat sync is a no-op - cross-sync widen: an entry tracked from an earlier sync grows when discovery evidences further indexes - window straddle: drives the actual window advance loop, asserts per window queried tags and that the widened entry later finalizes cleanly Store-level tests pin the union semantics: a sub-range keeps the entry, a range beyond it widens it, an untracked tx still stores. (cherry picked from commit fd7515d)
1 parent 910c043 commit 99632a7

6 files changed

Lines changed: 281 additions & 53 deletions

File tree

yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@ describe('SenderTaggingStore', () => {
3131
});
3232

3333
describe('storePendingIndexes', () => {
34-
it('stores a single pending index range', async () => {
34+
it.each([
35+
['', 'storePendingIndexes'],
36+
[' when merging', 'mergePendingIndexes'],
37+
] as const)('stores a single pending index range for an untracked tx%s', async (_name, method) => {
3538
const txHash = TxHash.random();
3639

37-
await taggingStore.storePendingIndexes([range(secret1, 5)], txHash, 'test');
40+
await taggingStore[method]([range(secret1, 5)], txHash, 'test');
3841

3942
const txHashes = await taggingStore.getTxHashesOfPendingIndexes(secret1, 0, 10, 'test');
40-
expect(txHashes).toHaveLength(1);
41-
expect(txHashes[0]).toEqual(txHash);
43+
expect(txHashes).toEqual([txHash]);
44+
expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(5);
4245
});
4346

4447
it('stores multiple pending index ranges for different secrets', async () => {
@@ -107,6 +110,32 @@ describe('SenderTaggingStore', () => {
107110
);
108111
});
109112

113+
it('keeps the existing range when merging in a sub-range for the same tx', async () => {
114+
const txHash = TxHash.random();
115+
116+
// Prove-time entry spanning setup and app-logic phase logs.
117+
await taggingStore.storePendingIndexes([range(secret1, 4, 6)], txHash, 'test');
118+
119+
// Discovery of the surviving sub-range of a partially reverted tx must not throw nor shrink the entry.
120+
await taggingStore.mergePendingIndexes([range(secret1, 4)], txHash, 'test');
121+
122+
expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(6);
123+
});
124+
125+
it('widens the existing range to the union when merging in a range beyond it', async () => {
126+
const txHash = TxHash.random();
127+
128+
// A prior window discovered only part of the tx's range.
129+
await taggingStore.storePendingIndexes([range(secret1, 4, 6)], txHash, 'test');
130+
131+
// Discovery evidences further onchain indexes for the same tx — the entry must grow to cover them.
132+
await taggingStore.mergePendingIndexes([range(secret1, 7, 8)], txHash, 'test');
133+
134+
const txHashes = await taggingStore.getTxHashesOfPendingIndexes(secret1, 0, 10, 'test');
135+
expect(txHashes).toEqual([txHash]);
136+
expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(8);
137+
});
138+
110139
it('throws when storing a pending index range lower than the last finalized index', async () => {
111140
const txHash1 = TxHash.random();
112141
const txHash2 = TxHash.random();

yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,11 @@ export class SenderTaggingStore implements StagedStore {
128128
}
129129

130130
/**
131-
* Stores pending index ranges.
131+
* Stores pending index ranges, rejecting any range that disagrees with an already-stored one.
132132
* @remarks If the same (secret, txHash) pair already exists in the db with an equal range, it's a no-op. This is
133133
* expected to happen because whenever we start sync we start from the last finalized index and we can have pending
134-
* ranges already stored from previous syncs. If the ranges differ, it throws an error as that indicates a bug.
134+
* ranges already stored from previous syncs. If the ranges differ, it throws an error as that indicates a bug in
135+
* callers that record indexes at prove time. Discovery from onchain logs must use `mergePendingIndexes` instead.
135136
* @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are
136137
* to be stored in the db.
137138
* @param txHash - The tx in which the tagging indexes were used in private logs.
@@ -141,6 +142,33 @@ export class SenderTaggingStore implements StagedStore {
141142
* @throws If a different range already exists for the same (secret, txHash) pair.
142143
*/
143144
storePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise<void> {
145+
return this.#storePendingIndexes(ranges, txHash, jobId, false);
146+
}
147+
148+
/**
149+
* Stores pending index ranges, widening an existing entry for the same (secret, txHash) pair to the union of the
150+
* stored and incoming ranges instead of throwing on a mismatch. Discovery from onchain logs needs this: it may see
151+
* only the surviving (non-revertible phase) sub-range of a partially reverted tx recorded at prove time (the
152+
* finalized receipt step of the sync resolves that difference), or indexes beyond a partially discovered entry
153+
* when a tx from another PXE straddles a sync window boundary. Callers that record indexes at prove time must use
154+
* `storePendingIndexes` instead, so that a range disagreement surfaces as a bug rather than being absorbed.
155+
* @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are
156+
* to be stored in the db.
157+
* @param txHash - The tx in which the tagging indexes were used in private logs.
158+
* @param jobId - job context for staged writes to this store. See `JobCoordinator` for more details.
159+
* @throws If the highestIndex is further than window length from the highest finalized index for the same secret.
160+
* @throws If the lowestIndex is lower than or equal to the last finalized index for the same secret.
161+
*/
162+
mergePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise<void> {
163+
return this.#storePendingIndexes(ranges, txHash, jobId, true);
164+
}
165+
166+
#storePendingIndexes(
167+
ranges: TaggingIndexRange[],
168+
txHash: TxHash,
169+
jobId: string,
170+
mergeExisting: boolean,
171+
): Promise<void> {
144172
if (ranges.length === 0) {
145173
return Promise.resolve();
146174
}
@@ -187,21 +215,40 @@ export class SenderTaggingStore implements StagedStore {
187215
// Check if an entry with the same txHash already exists
188216
const existingEntry = pendingData.find(entry => entry.txHash === txHashStr);
189217

190-
if (existingEntry) {
191-
// Assert that the ranges are equal — different ranges for the same (secret, txHash) indicates a bug
192-
if (existingEntry.lowestIndex !== range.lowestIndex || existingEntry.highestIndex !== range.highestIndex) {
193-
throw new Error(
194-
`Conflicting range for secret ${secretStr} and txHash ${txHashStr}: ` +
195-
`existing [${existingEntry.lowestIndex}, ${existingEntry.highestIndex}] vs ` +
196-
`new [${range.lowestIndex}, ${range.highestIndex}]`,
197-
);
198-
}
199-
// Exact duplicate — skip
200-
} else {
201-
this.#writePendingIndexes(jobId, secretStr, [
218+
let updatedPending: PendingIndexesEntry[] | undefined;
219+
if (!existingEntry) {
220+
updatedPending = [
202221
...pendingData,
203222
{ lowestIndex: range.lowestIndex, highestIndex: range.highestIndex, txHash: txHashStr },
204-
]);
223+
];
224+
} else if (mergeExisting) {
225+
// Widen the entry to the union of both ranges, never shrink it: replacing would drop prove-time
226+
// indexes the chain doesn't show (partially reverted tx), and skipping would drop onchain indexes
227+
// discovered in a later sync window (tx straddling the window boundary).
228+
const lowestIndex = Math.min(existingEntry.lowestIndex, range.lowestIndex);
229+
const highestIndex = Math.max(existingEntry.highestIndex, range.highestIndex);
230+
if (lowestIndex !== existingEntry.lowestIndex || highestIndex !== existingEntry.highestIndex) {
231+
updatedPending = pendingData.map(entry =>
232+
entry === existingEntry ? { lowestIndex, highestIndex, txHash: entry.txHash } : entry,
233+
);
234+
}
235+
} else if (
236+
existingEntry.lowestIndex !== range.lowestIndex ||
237+
existingEntry.highestIndex !== range.highestIndex
238+
) {
239+
// Different ranges for the same (secret, txHash) indicate a bug in callers that record indexes at prove
240+
// time.
241+
throw new Error(
242+
`Conflicting range for secret ${secretStr} and txHash ${txHashStr}: ` +
243+
`existing [${existingEntry.lowestIndex}, ${existingEntry.highestIndex}] vs ` +
244+
`new [${range.lowestIndex}, ${range.highestIndex}]`,
245+
);
246+
}
247+
// Remaining cases (a merge whose union equals the stored range, or an identical range without
248+
// mergeExisting): duplicate evidence, nothing to write.
249+
250+
if (updatedPending) {
251+
this.#writePendingIndexes(jobId, secretStr, updatedPending);
205252
}
206253
}
207254
});

yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -394,26 +394,34 @@ describe('syncSenderTaggingIndexes', () => {
394394
expect(aztecNode.getTxReceipt).toHaveBeenCalledWith(pendingTxHash);
395395
});
396396

397-
it('handles a partially reverted transaction', async () => {
397+
/**
398+
* Same-PXE partial revert: the pending range was recorded at prove time and spans both the non-revertible (setup)
399+
* and revertible (app logic) phases. After the tx mines with reverted app logic, only the setup-phase logs are
400+
* onchain, so discovery re-derives a narrower range for the same (secret, txHash). That narrower range must not
401+
* conflict with the prove-time entry — the finalized receipt step of the sync owns resolving the difference.
402+
*/
403+
it('handles a partially reverted tx whose pending range was recorded at prove time', async () => {
398404
await setUp();
399405

400406
const revertedTxHash = TxHash.random();
401407

402-
// Create logs at indexes 4 and 6 for the same (reverted) tx
408+
// Prove-time persist: logs at indexes 4 (setup phase) through 6 (app logic phase) under the same secret.
409+
await taggingStore.storePendingIndexes(
410+
[{ extendedSecret: secret, lowestIndex: 4, highestIndex: 6 }],
411+
revertedTxHash,
412+
'test',
413+
);
414+
415+
// Only the setup-phase log survived the revert, so the node only knows the tag at index 4.
403416
const tag4 = await computeSiloedTagForIndex(4);
417+
// The app-logic tags at indexes 5 and 6 were squashed by the revert and never reached the chain.
418+
const tag5 = await computeSiloedTagForIndex(5);
404419
const tag6 = await computeSiloedTagForIndex(6);
405420

406421
aztecNode.getPrivateLogsByTags.mockImplementation(query => {
407422
const tags = query.tags as SiloedTag[];
408423
return Promise.resolve(
409-
tags.map((tag: SiloedTag) => {
410-
if (tag.equals(tag4)) {
411-
return [makeLog(revertedTxHash, tag4.value)];
412-
} else if (tag.equals(tag6)) {
413-
return [makeLog(revertedTxHash, tag6.value)];
414-
}
415-
return [];
416-
}),
424+
tags.map((tag: SiloedTag) => (tag.equals(tag4) ? [makeLog(revertedTxHash, tag4.value)] : [])),
417425
);
418426
});
419427

@@ -432,17 +440,157 @@ describe('syncSenderTaggingIndexes', () => {
432440
[], // contractClassLogs
433441
);
434442

435-
// Mock getTxReceipt to return a FINALIZED + REVERTED mined receipt carrying the tx effect. The same receipt
436-
// satisfies both the status-classification call and the includeTxEffect follow-up call.
437443
aztecNode.getTxReceipt.mockResolvedValue(
438444
mined(revertedTxHash, TxStatus.FINALIZED, 14, TxExecutionResult.REVERTED, txEffect),
439445
);
440446

441447
await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test');
442448

443-
// Index 4 should be finalized (it survived the partial revert)
449+
// The surviving index is finalized and the squashed indexes 5-6 are freed for reuse.
444450
expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(4);
445-
// No pending indexes should remain for this secret
446451
expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(4);
452+
// Reconciliation must remove the pending entry entirely — a stale entry would keep resurfacing in later syncs.
453+
const pendingAfterSync = await taggingStore.getTxHashesOfPendingIndexes(
454+
secret,
455+
0,
456+
UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN,
457+
'test',
458+
);
459+
expect(pendingAfterSync).toEqual([]);
460+
461+
// Premise guard: discovery blindly probes every index in the window, so the first sync must have queried the full
462+
// prove-time range [4, 6], with only the setup-phase tag getting an onchain answer. If the sync ever stopped
463+
// probing these indexes, the discovery merge this test exists to exercise would silently stop happening.
464+
const queriedTags = aztecNode.getPrivateLogsByTags.mock.calls.flatMap(([query]) => query.tags as SiloedTag[]);
465+
expect(queriedTags.some(tag => tag.equals(tag4))).toBe(true);
466+
expect(queriedTags.some(tag => tag.equals(tag5))).toBe(true);
467+
expect(queriedTags.some(tag => tag.equals(tag6))).toBe(true);
468+
469+
// A repeat sync must be a clean no-op: the behavior being pinned here is that the secret will not throw on every
470+
// subsequent sync.
471+
await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test');
472+
473+
expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(4);
474+
expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(4);
475+
});
476+
477+
/**
478+
* Cross-device straddle: another PXE sharing this directional secret sent a tx, and an earlier window discovered
479+
* only part of its index range, so the store already tracks a narrower entry for the same (secret, txHash).
480+
* Discovery must widen the entry to cover every index evidenced onchain, so that the next index choice accounts
481+
* for them.
482+
*/
483+
it('widens a tracked pending range when discovery evidences further indexes for the same tx', async () => {
484+
await setUp();
485+
486+
const foreignTxHash = TxHash.random();
487+
488+
// An earlier window discovered only the first index of the foreign tx.
489+
await taggingStore.storePendingIndexes(
490+
[{ extendedSecret: secret, lowestIndex: 10, highestIndex: 10 }],
491+
foreignTxHash,
492+
'test',
493+
);
494+
495+
// The chain shows the tx actually used indexes 10 and 11.
496+
const tag10 = await computeSiloedTagForIndex(10);
497+
const tag11 = await computeSiloedTagForIndex(11);
498+
499+
aztecNode.getPrivateLogsByTags.mockImplementation(query => {
500+
const tags = query.tags as SiloedTag[];
501+
return Promise.resolve(
502+
tags.map((tag: SiloedTag) => {
503+
if (tag.equals(tag10)) {
504+
return [makeLog(foreignTxHash, tag10.value)];
505+
} else if (tag.equals(tag11)) {
506+
return [makeLog(foreignTxHash, tag11.value)];
507+
}
508+
return [];
509+
}),
510+
);
511+
});
512+
513+
// The tx is mined but not yet finalized, so no receipt status change resolves the entry during this sync.
514+
aztecNode.getTxReceipt.mockResolvedValue(mined(foreignTxHash, TxStatus.PROPOSED, 14));
515+
516+
await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test');
517+
518+
// The next index choice must account for the onchain tag at index 11.
519+
expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(11);
520+
expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBeUndefined();
521+
});
522+
523+
/**
524+
* Single-sync window straddle: a foreign tx's tags span the boundary between two consecutive sync windows, so the
525+
* window loop assembles the tx's range piecewise — window 1 stores the lower index, window 2 evidences the higher
526+
* one for the same (secret, txHash). The second write must widen the entry from window 1 rather than conflict with
527+
* it, and the final index choice must cover the full onchain range.
528+
*/
529+
it('assembles a pending range piecewise when a tx straddles the sync window boundary', async () => {
530+
await setUp();
531+
532+
// A tx finalized at index 0 makes the finalized index advance during window 1, so the loop proceeds to window 2.
533+
const finalizedTxHash = TxHash.random();
534+
const finalizedTag = await computeSiloedTagForIndex(0);
535+
536+
// The straddling tx used the last index of window 1 and the first index of window 2.
537+
const straddlingTxHash = TxHash.random();
538+
const lowerStraddleIndex = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN - 1;
539+
const upperStraddleIndex = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN;
540+
const lowerStraddleTag = await computeSiloedTagForIndex(lowerStraddleIndex);
541+
const upperStraddleTag = await computeSiloedTagForIndex(upperStraddleIndex);
542+
543+
aztecNode.getPrivateLogsByTags.mockImplementation(query => {
544+
const tags = query.tags as SiloedTag[];
545+
return Promise.resolve(
546+
tags.map((tag: SiloedTag) => {
547+
if (tag.equals(finalizedTag)) {
548+
return [makeLog(finalizedTxHash, finalizedTag.value)];
549+
} else if (tag.equals(lowerStraddleTag)) {
550+
return [makeLog(straddlingTxHash, lowerStraddleTag.value)];
551+
} else if (tag.equals(upperStraddleTag)) {
552+
return [makeLog(straddlingTxHash, upperStraddleTag.value)];
553+
}
554+
return [];
555+
}),
556+
);
557+
});
558+
559+
aztecNode.getTxReceipt.mockImplementation((hash: TxHash) => {
560+
if (hash.equals(finalizedTxHash)) {
561+
return Promise.resolve(mined(hash, TxStatus.FINALIZED, 14));
562+
} else if (hash.equals(straddlingTxHash)) {
563+
// Mined but not finalized, so no receipt status change resolves the straddling entry during this sync.
564+
return Promise.resolve(mined(hash, TxStatus.PROPOSED, 16));
565+
}
566+
throw new Error(`Unexpected tx hash: ${hash.toString()}`);
567+
});
568+
569+
await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test');
570+
571+
// The straddled range must have been assembled piecewise: window 1's logs query covers the lower straddle index
572+
// and window 2's the upper. (Each window fits in a single RPC page, so there is one logs call per window.)
573+
expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2);
574+
const queriedTags = aztecNode.getPrivateLogsByTags.mock.calls.map(([query]) => query.tags as SiloedTag[]);
575+
expect(queriedTags[0].some(tag => tag.equals(lowerStraddleTag))).toBe(true);
576+
expect(queriedTags[1].some(tag => tag.equals(upperStraddleTag))).toBe(true);
577+
578+
expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(0);
579+
// The next index choice must account for both straddled onchain tags.
580+
expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(upperStraddleIndex);
581+
582+
// The straddling tx later finalizes. A single widened entry finalizes cleanly at the upper index — a duplicate
583+
// entry for the same txHash would instead trip the multiple-pending-entries guard during finalization.
584+
aztecNode.getTxReceipt.mockImplementation((hash: TxHash) => {
585+
if (hash.equals(straddlingTxHash)) {
586+
return Promise.resolve(mined(hash, TxStatus.FINALIZED, 18));
587+
}
588+
throw new Error(`Unexpected tx hash: ${hash.toString()}`);
589+
});
590+
591+
await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test');
592+
593+
expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(upperStraddleIndex);
594+
expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(upperStraddleIndex);
447595
});
448596
});

yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export async function syncSenderTaggingIndexes(
7474
}
7575

7676
// Receipts for pending tx hashes that the logs query just surfaced still need a sequential follow-up call.
77-
// `storePendingIndexes` is idempotent on (secret, txHash), so a re-discovered hash stays classified as known
77+
// `mergePendingIndexes` is idempotent on (secret, txHash), so a re-discovered hash stays classified as known
7878
// and is not re-fetched here.
7979
const knownSet = new Set(knownPendingTxHashes.map(h => h.toString()));
8080
const newPendingTxHashes = allPendingTxHashes.filter(h => !knownSet.has(h.toString()));

0 commit comments

Comments
 (0)