@@ -17,6 +17,7 @@ import {
1717 IndexerManagementClient ,
1818 MultiNetworks ,
1919} from '@graphprotocol/indexer-common'
20+ import type { SubgraphIndexingAgreement } from '../agreement-monitor'
2021import {
2122 connectDatabase ,
2223 createLogger ,
@@ -458,6 +459,240 @@ describe('DipsManager', () => {
458459 expect ( deployments ) . toHaveLength ( 1 )
459460 expect ( deployments [ 0 ] . ipfsHash ) . toBe ( testDeploymentId )
460461 } )
462+
463+ describe ( 'cancelAgreement' , ( ) => {
464+ const mockAgreement : SubgraphIndexingAgreement = {
465+ id : '0x123e4567e89b12d3a456426614174000' ,
466+ allocationId : '0xabcd47df40c29949a75a6693c77834c00b8ad626' ,
467+ subgraphDeploymentId : 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7' ,
468+ state : 1 , // Accepted
469+ lastCollectionAt : '0' ,
470+ endsAt : '9999999999' ,
471+ maxInitialTokens : '1000' ,
472+ maxOngoingTokensPerSecond : '100' ,
473+ tokensPerSecond : '10' ,
474+ tokensPerEntityPerSecond : '1' ,
475+ minSecondsPerCollection : 60 ,
476+ maxSecondsPerCollection : 300 ,
477+ canceledAt : '0' ,
478+ }
479+
480+ beforeEach ( ( ) => {
481+ // Track the agreement so we can verify cleanup
482+ dipsManager . collectionTracker . track ( mockAgreement . id , {
483+ lastCollectedAt : 0 ,
484+ minSecondsPerCollection : 60 ,
485+ maxSecondsPerCollection : 300 ,
486+ } )
487+ } )
488+
489+ test ( 'successful cancel + final collect attempt' , async ( ) => {
490+ const mockReceipt = { hash : '0xcancel123' }
491+ const mockCollectReceipt = { hash : '0xcollect456' }
492+
493+ // Mock cancel transaction
494+ network . transactionManager . executeTransaction = jest
495+ . fn ( )
496+ . mockResolvedValueOnce ( mockReceipt ) // cancel
497+ . mockResolvedValueOnce ( mockCollectReceipt ) // collect
498+
499+ // Mock block number and graph node methods for collect
500+ network . networkProvider . getBlockNumber = jest . fn ( ) . mockResolvedValue ( 100 )
501+ graphNode . entityCount = jest . fn ( ) . mockResolvedValue ( [ 250000 ] )
502+ graphNode . subgraphFeatures = jest
503+ . fn ( )
504+ . mockResolvedValue ( { network : 'mainnet' } )
505+ graphNode . blockHashFromNumber = jest
506+ . fn ( )
507+ . mockResolvedValue ( '0xblockhash' )
508+ graphNode . proofOfIndexing = jest
509+ . fn ( )
510+ . mockResolvedValue (
511+ '0x0000000000000000000000000000000000000000000000000000000000000001' ,
512+ )
513+
514+ const result = await dipsManager . cancelAgreement (
515+ mockAgreement . id ,
516+ mockAgreement ,
517+ )
518+
519+ expect ( result ) . toBe ( true )
520+ // executeTransaction called twice: once for cancel, once for collect
521+ expect ( network . transactionManager . executeTransaction ) . toHaveBeenCalledTimes ( 2 )
522+ // Tracker should be cleaned up (untracked = ready)
523+ expect (
524+ dipsManager . collectionTracker . isReadyForCollection ( mockAgreement . id , 0 ) ,
525+ ) . toBe ( true )
526+ } )
527+
528+ test ( 'cancel fails returns false, no collect attempted' , async ( ) => {
529+ // Mock cancel transaction failure
530+ network . transactionManager . executeTransaction = jest
531+ . fn ( )
532+ . mockRejectedValueOnce ( new Error ( 'cancel tx reverted' ) )
533+
534+ const result = await dipsManager . cancelAgreement (
535+ mockAgreement . id ,
536+ mockAgreement ,
537+ )
538+
539+ expect ( result ) . toBe ( false )
540+ // executeTransaction called only once (for cancel)
541+ expect ( network . transactionManager . executeTransaction ) . toHaveBeenCalledTimes ( 1 )
542+ } )
543+
544+ test ( 'cancel succeeds but collect fails returns true, tracker still cleaned up' , async ( ) => {
545+ const mockReceipt = { hash : '0xcancel123' }
546+
547+ // Mock cancel succeeds
548+ network . transactionManager . executeTransaction = jest
549+ . fn ( )
550+ . mockResolvedValueOnce ( mockReceipt ) // cancel succeeds
551+ . mockRejectedValueOnce ( new Error ( 'collect failed' ) ) // collect fails
552+
553+ // Mock block number and graph node methods
554+ network . networkProvider . getBlockNumber = jest . fn ( ) . mockResolvedValue ( 100 )
555+ graphNode . entityCount = jest . fn ( ) . mockResolvedValue ( [ 250000 ] )
556+ graphNode . subgraphFeatures = jest
557+ . fn ( )
558+ . mockResolvedValue ( { network : 'mainnet' } )
559+ graphNode . blockHashFromNumber = jest
560+ . fn ( )
561+ . mockResolvedValue ( '0xblockhash' )
562+ graphNode . proofOfIndexing = jest
563+ . fn ( )
564+ . mockResolvedValue (
565+ '0x0000000000000000000000000000000000000000000000000000000000000001' ,
566+ )
567+
568+ const result = await dipsManager . cancelAgreement (
569+ mockAgreement . id ,
570+ mockAgreement ,
571+ )
572+
573+ expect ( result ) . toBe ( true )
574+ // Tracker should be cleaned up even though collect failed
575+ expect (
576+ dipsManager . collectionTracker . isReadyForCollection ( mockAgreement . id , 0 ) ,
577+ ) . toBe ( true )
578+ } )
579+ } )
580+
581+ describe ( 'cleanupFinishedAgreement' , ( ) => {
582+ const baseAgreement : SubgraphIndexingAgreement = {
583+ id : '0x123e4567e89b12d3a456426614174000' ,
584+ allocationId : '0xabcd47df40c29949a75a6693c77834c00b8ad626' ,
585+ subgraphDeploymentId : 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7' ,
586+ state : 1 ,
587+ lastCollectionAt : '0' ,
588+ endsAt : '9999999999' ,
589+ maxInitialTokens : '1000' ,
590+ maxOngoingTokensPerSecond : '100' ,
591+ tokensPerSecond : '10' ,
592+ tokensPerEntityPerSecond : '1' ,
593+ minSecondsPerCollection : 60 ,
594+ maxSecondsPerCollection : 300 ,
595+ canceledAt : '0' ,
596+ }
597+
598+ beforeEach ( ( ) => {
599+ dipsManager . collectionTracker . track ( baseAgreement . id , {
600+ lastCollectedAt : 0 ,
601+ minSecondsPerCollection : 60 ,
602+ maxSecondsPerCollection : 300 ,
603+ } )
604+ } )
605+
606+ test ( 'removes payer-cancelled agreement from tracker after collection' , ( ) => {
607+ const removeSpy = jest . spyOn ( dipsManager . collectionTracker , 'remove' )
608+ const agreement = { ...baseAgreement , state : 3 }
609+
610+ const result = dipsManager . cleanupFinishedAgreement ( agreement , 1000 , logger )
611+
612+ expect ( result ) . toBe ( true )
613+ expect ( removeSpy ) . toHaveBeenCalledWith ( agreement . id )
614+ } )
615+
616+ test ( 'removes expired agreement from tracker after collection' , ( ) => {
617+ const removeSpy = jest . spyOn ( dipsManager . collectionTracker , 'remove' )
618+ const nowSeconds = 2000
619+ const agreement = { ...baseAgreement , state : 1 , endsAt : '1000' }
620+
621+ const result = dipsManager . cleanupFinishedAgreement (
622+ agreement ,
623+ nowSeconds ,
624+ logger ,
625+ )
626+
627+ expect ( result ) . toBe ( true )
628+ expect ( removeSpy ) . toHaveBeenCalledWith ( agreement . id )
629+ } )
630+
631+ test ( 'does not remove active agreement from tracker' , ( ) => {
632+ const removeSpy = jest . spyOn ( dipsManager . collectionTracker , 'remove' )
633+ const nowSeconds = 1000
634+ const agreement = { ...baseAgreement , state : 1 , endsAt : '9999999999' }
635+
636+ const result = dipsManager . cleanupFinishedAgreement (
637+ agreement ,
638+ nowSeconds ,
639+ logger ,
640+ )
641+
642+ expect ( result ) . toBe ( false )
643+ expect ( removeSpy ) . not . toHaveBeenCalled ( )
644+ } )
645+ } )
646+
647+ describe ( 'cancelBlocklistedAgreements' , ( ) => {
648+ const mockAgreement : SubgraphIndexingAgreement = {
649+ id : '0x123e4567e89b12d3a456426614174000' ,
650+ allocationId : '0xabcd47df40c29949a75a6693c77834c00b8ad626' ,
651+ subgraphDeploymentId : 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7' ,
652+ state : 1 ,
653+ lastCollectionAt : '0' ,
654+ endsAt : '9999999999' ,
655+ maxInitialTokens : '1000' ,
656+ maxOngoingTokensPerSecond : '100' ,
657+ tokensPerSecond : '10' ,
658+ tokensPerEntityPerSecond : '1' ,
659+ minSecondsPerCollection : 60 ,
660+ maxSecondsPerCollection : 300 ,
661+ canceledAt : '0' ,
662+ }
663+
664+ test ( 'cancels agreements with NEVER rule for their deployment' , async ( ) => {
665+ // Create a NEVER rule for the test deployment
666+ await managementModels . IndexingRule . create ( {
667+ identifier : testDeploymentId ,
668+ identifierType : SubgraphIdentifierType . DEPLOYMENT ,
669+ decisionBasis : IndexingDecisionBasis . NEVER ,
670+ requireSupported : true ,
671+ safety : true ,
672+ protocolNetwork : 'eip155:421614' ,
673+ allocationAmount : '0' ,
674+ } )
675+
676+ const cancelSpy = jest
677+ . spyOn ( dipsManager , 'cancelAgreement' )
678+ . mockResolvedValue ( true )
679+
680+ await dipsManager . cancelBlocklistedAgreements ( [ mockAgreement ] )
681+
682+ expect ( cancelSpy ) . toHaveBeenCalledTimes ( 1 )
683+ expect ( cancelSpy ) . toHaveBeenCalledWith ( mockAgreement . id , mockAgreement )
684+ } )
685+
686+ test ( 'does not cancel agreements without NEVER rule' , async ( ) => {
687+ const cancelSpy = jest
688+ . spyOn ( dipsManager , 'cancelAgreement' )
689+ . mockResolvedValue ( true )
690+
691+ await dipsManager . cancelBlocklistedAgreements ( [ mockAgreement ] )
692+
693+ expect ( cancelSpy ) . not . toHaveBeenCalled ( )
694+ } )
695+ } )
461696 } )
462697} )
463698
0 commit comments