@@ -443,6 +443,141 @@ describe('SorobanEventWorker', () => {
443443 expect ( logger . warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Duplicate StreamEvent skipped' ) ) ;
444444 } ) ;
445445
446+ it ( 'should not double-increment withdrawnAmount when a tokens_withdrawn event is re-processed' , async ( ) => {
447+ const txHash = 'withdraw-tx-hash' ;
448+ const streamId = 21 ;
449+
450+ const mockEvent : rpc . Api . EventResponse = {
451+ id : 'withdraw-event-1' ,
452+ type : 'contract' ,
453+ ledger : 4000 ,
454+ ledgerClosedAt : '2024-01-01T00:00:00Z' ,
455+ txHash,
456+ transactionIndex : 0 ,
457+ operationIndex : 0 ,
458+ inSuccessfulContractCall : true ,
459+ topic : [
460+ { switch : ( ) => ( { value : 0 } ) , sym : ( ) => 'tokens_withdrawn' } as any ,
461+ { switch : ( ) => ( { value : 1 } ) , u64 : ( ) => ( { toString : ( ) => streamId . toString ( ) } ) } as any ,
462+ ] ,
463+ value : {
464+ switch : ( ) => ( { value : 4 } ) ,
465+ map : ( ) => [
466+ { key : ( ) => ( { sym : ( ) => 'recipient' } ) , val : ( ) => ( { address : ( ) => ( { switch : ( ) => ( { value : 0 } ) , accountId : ( ) => ( { ed25519 : ( ) => Buffer . alloc ( 32 ) } ) } ) } ) } ,
467+ { key : ( ) => ( { sym : ( ) => 'amount' } ) , val : ( ) => ( { i128 : ( ) => ( { hi : ( ) => ( { toString : ( ) => '0' } ) , lo : ( ) => ( { toString : ( ) => '500' } ) } ) } ) } ,
468+ { key : ( ) => ( { sym : ( ) => 'timestamp' } ) , val : ( ) => ( { u64 : ( ) => ( { toString : ( ) => '1700002000' } ) } ) } ,
469+ ] as any ,
470+ } as any ,
471+ } ;
472+
473+ // withdrawnAmount starts at '1000'; a single successful withdrawal of
474+ // 500 should bring it to '1500' and stay there under replay.
475+ const mockTx = {
476+ stream : {
477+ findUniqueOrThrow : vi . fn ( ) . mockResolvedValue ( { withdrawnAmount : '1000' } ) ,
478+ update : vi . fn ( ) . mockResolvedValue ( { } ) ,
479+ } ,
480+ streamEvent : {
481+ findUnique : vi . fn ( ) ,
482+ upsert : vi . fn ( ) . mockResolvedValue ( { id : 'withdraw-event-row' } ) ,
483+ } ,
484+ } ;
485+
486+ ( prisma . $transaction as ReturnType < typeof vi . fn > ) . mockImplementation ( ( cb ) => cb ( mockTx ) ) ;
487+
488+ // First processing: no existing event → withdrawnAmount is updated once.
489+ mockTx . streamEvent . findUnique . mockResolvedValueOnce ( null ) ;
490+ await expect ( ( worker as any ) . handleTokensWithdrawn ( mockEvent , mockEvent . topic ! [ 1 ] ) ) . resolves . not . toThrow ( ) ;
491+ expect ( mockTx . stream . update ) . toHaveBeenCalledTimes ( 1 ) ;
492+ expect ( mockTx . stream . update ) . toHaveBeenCalledWith ( {
493+ where : { streamId } ,
494+ data : { withdrawnAmount : '1500' , lastUpdateTime : 1700002000 } ,
495+ } ) ;
496+ expect ( mockTx . streamEvent . upsert ) . toHaveBeenCalledTimes ( 1 ) ;
497+ expect ( logger . warn ) . not . toHaveBeenCalled ( ) ;
498+
499+ vi . clearAllMocks ( ) ;
500+ ( prisma . $transaction as ReturnType < typeof vi . fn > ) . mockImplementation ( ( cb ) => cb ( mockTx ) ) ;
501+
502+ // Second processing (replay of same txHash): the event now exists, so
503+ // withdrawnAmount must NOT be touched a second time.
504+ mockTx . streamEvent . findUnique . mockResolvedValueOnce ( { id : 'withdraw-event-row' } ) ;
505+ await expect ( ( worker as any ) . handleTokensWithdrawn ( mockEvent , mockEvent . topic ! [ 1 ] ) ) . resolves . not . toThrow ( ) ;
506+ expect ( mockTx . stream . update ) . not . toHaveBeenCalled ( ) ;
507+ expect ( mockTx . streamEvent . upsert ) . not . toHaveBeenCalled ( ) ;
508+ expect ( logger . warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Duplicate StreamEvent skipped' ) ) ;
509+ } ) ;
510+
511+ it ( 'should not double-apply depositedAmount/endTime when a stream_topped_up event is re-processed' , async ( ) => {
512+ const txHash = 'topup-tx-hash' ;
513+ const streamId = 22 ;
514+
515+ const mockEvent : rpc . Api . EventResponse = {
516+ id : 'topup-event-1' ,
517+ type : 'contract' ,
518+ ledger : 4001 ,
519+ ledgerClosedAt : '2024-01-01T00:00:00Z' ,
520+ txHash,
521+ transactionIndex : 0 ,
522+ operationIndex : 0 ,
523+ inSuccessfulContractCall : true ,
524+ topic : [
525+ { switch : ( ) => ( { value : 0 } ) , sym : ( ) => 'stream_topped_up' } as any ,
526+ { switch : ( ) => ( { value : 1 } ) , u64 : ( ) => ( { toString : ( ) => streamId . toString ( ) } ) } as any ,
527+ ] ,
528+ value : {
529+ switch : ( ) => ( { value : 4 } ) ,
530+ map : ( ) => [
531+ { key : ( ) => ( { sym : ( ) => 'amount' } ) , val : ( ) => ( { i128 : ( ) => ( { hi : ( ) => ( { toString : ( ) => '0' } ) , lo : ( ) => ( { toString : ( ) => '200' } ) } ) } ) } ,
532+ { key : ( ) => ( { sym : ( ) => 'new_deposited_amount' } ) , val : ( ) => ( { i128 : ( ) => ( { hi : ( ) => ( { toString : ( ) => '0' } ) , lo : ( ) => ( { toString : ( ) => '1200' } ) } ) } ) } ,
533+ ] as any ,
534+ } as any ,
535+ } ;
536+
537+ const mockTx = {
538+ stream : {
539+ findUniqueOrThrow : vi . fn ( ) . mockResolvedValue ( {
540+ ratePerSecond : '10' ,
541+ startTime : 1700000000 ,
542+ totalPausedDuration : 0 ,
543+ } ) ,
544+ update : vi . fn ( ) . mockResolvedValue ( { } ) ,
545+ } ,
546+ streamEvent : {
547+ findUnique : vi . fn ( ) ,
548+ upsert : vi . fn ( ) . mockResolvedValue ( { id : 'topup-event-row' } ) ,
549+ } ,
550+ } ;
551+
552+ ( prisma . $transaction as ReturnType < typeof vi . fn > ) . mockImplementation ( ( cb ) => cb ( mockTx ) ) ;
553+
554+ // First processing: no existing event → depositedAmount/endTime are set once.
555+ mockTx . streamEvent . findUnique . mockResolvedValueOnce ( null ) ;
556+ await expect ( ( worker as any ) . handleStreamToppedUp ( mockEvent , mockEvent . topic ! [ 1 ] ) ) . resolves . not . toThrow ( ) ;
557+ expect ( mockTx . stream . update ) . toHaveBeenCalledTimes ( 1 ) ;
558+ const firstUpdateArgs = mockTx . stream . update . mock . calls [ 0 ] ! [ 0 ] ;
559+ expect ( firstUpdateArgs . data . depositedAmount ) . toBe ( '1200' ) ;
560+ const expectedEndTime = firstUpdateArgs . data . endTime ;
561+ expect ( mockTx . streamEvent . upsert ) . toHaveBeenCalledTimes ( 1 ) ;
562+ expect ( logger . warn ) . not . toHaveBeenCalled ( ) ;
563+
564+ vi . clearAllMocks ( ) ;
565+ ( prisma . $transaction as ReturnType < typeof vi . fn > ) . mockImplementation ( ( cb ) => cb ( mockTx ) ) ;
566+
567+ // Second processing (replay of same txHash): the event now exists, so
568+ // depositedAmount/endTime must NOT be re-applied.
569+ mockTx . streamEvent . findUnique . mockResolvedValueOnce ( { id : 'topup-event-row' } ) ;
570+ await expect ( ( worker as any ) . handleStreamToppedUp ( mockEvent , mockEvent . topic ! [ 1 ] ) ) . resolves . not . toThrow ( ) ;
571+ expect ( mockTx . stream . update ) . not . toHaveBeenCalled ( ) ;
572+ expect ( mockTx . streamEvent . upsert ) . not . toHaveBeenCalled ( ) ;
573+ expect ( logger . warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Duplicate StreamEvent skipped' ) ) ;
574+
575+ // Sanity check: depositedAmount/endTime from the (only) applied update
576+ // match what a single application should produce.
577+ expect ( firstUpdateArgs . data . depositedAmount ) . toBe ( '1200' ) ;
578+ expect ( expectedEndTime ) . toBe ( 1700000000 + Math . floor ( 1200 / 10 ) + 0 ) ;
579+ } ) ;
580+
446581 it ( 'should process admin_transferred events successfully' , async ( ) => {
447582 const txHash = 'admin-transferred-tx-hash' ;
448583
0 commit comments