@@ -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} ) ;
0 commit comments