@@ -9,7 +9,9 @@ use tokio::sync::watch;
99use crate :: config:: ParticipantsConfig ;
1010use crate :: indexer:: foreign_chain:: ForeignChainSupporters ;
1111use crate :: primitives:: ParticipantId ;
12+ use crate :: requests:: queue:: RefineEligibleLeaders ;
1213use crate :: tracking:: { self , AutoAbortTask } ;
14+ use crate :: types:: VerifyForeignTxRequest ;
1315
1416/// Participants supporting each available foreign chain; chains without a
1517/// signing quorum are omitted.
@@ -129,6 +131,63 @@ fn resolve_participant_ids(
129131 . collect ( )
130132}
131133
134+ /// Narrows verify-foreign-tx leader selection to the participants supporting the
135+ /// request's chain, always reading the freshest supporters snapshot. A leader is
136+ /// only elected when a quorum of supporters is eligible, so an under-quorum
137+ /// request parks instead of burning attempts that cannot find a compatible
138+ /// presignature.
139+ pub ( crate ) struct ForeignChainLeadersRefiner {
140+ supporters_receiver : watch:: Receiver < SupportersByForeignChain > ,
141+ /// [`foreign_tx_reconstruction_threshold`] of the running domains, `None`
142+ /// when there is no ForeignTx domain (the snapshot is then always empty).
143+ quorum : Option < ReconstructionThreshold > ,
144+ }
145+
146+ impl ForeignChainLeadersRefiner {
147+ pub ( crate ) fn new (
148+ supporters_receiver : watch:: Receiver < SupportersByForeignChain > ,
149+ quorum : Option < ReconstructionThreshold > ,
150+ ) -> Self {
151+ ForeignChainLeadersRefiner {
152+ supporters_receiver,
153+ quorum,
154+ }
155+ }
156+ }
157+
158+ impl RefineEligibleLeaders < VerifyForeignTxRequest > for ForeignChainLeadersRefiner {
159+ fn refine (
160+ & self ,
161+ request : & VerifyForeignTxRequest ,
162+ eligible : & HashSet < ParticipantId > ,
163+ ) -> HashSet < ParticipantId > {
164+ let refined = match self
165+ . supporters_receiver
166+ . borrow ( )
167+ . get ( & request. request . chain ( ) )
168+ {
169+ Some ( supporters) => supporters & eligible,
170+ None => return HashSet :: new ( ) ,
171+ } ;
172+ match self . quorum {
173+ None => {
174+ tracing:: error!(
175+ chain = ?request. request. chain( ) ,
176+ "chain has supporters but there is no ForeignTx domain, this should never happen"
177+ ) ;
178+ refined
179+ }
180+ Some ( quorum) => {
181+ if u64:: try_from ( refined. len ( ) ) . is_ok_and ( |count| count >= quorum. inner ( ) ) {
182+ refined
183+ } else {
184+ HashSet :: new ( )
185+ }
186+ }
187+ }
188+ }
189+ }
190+
132191#[ cfg( test) ]
133192#[ expect( non_snake_case) ]
134193mod tests {
@@ -370,4 +429,131 @@ mod tests {
370429 } ) ;
371430 root. await ;
372431 }
432+
433+ fn bitcoin_verify_foreign_tx_request ( ) -> VerifyForeignTxRequest {
434+ VerifyForeignTxRequest {
435+ id : near_indexer_primitives:: CryptoHash ( [ 1 ; 32 ] ) ,
436+ receipt_id : near_indexer_primitives:: CryptoHash ( [ 2 ; 32 ] ) ,
437+ request : dtos:: ForeignChainRpcRequest :: Bitcoin ( dtos:: BitcoinRpcRequest {
438+ tx_id : dtos:: BitcoinTxId ( [ 3 ; 32 ] ) ,
439+ confirmations : 2 . into ( ) ,
440+ extractors : vec ! [ dtos:: BitcoinExtractor :: BlockHash ] ,
441+ } ) ,
442+ payload_version : dtos:: ForeignTxPayloadVersion :: V1 ,
443+ expected_payload_hash : None ,
444+ entropy : [ 4 ; 32 ] ,
445+ timestamp_nanosec : 0 ,
446+ domain_id : mpc_primitives:: domain:: DomainId ( 0 ) ,
447+ }
448+ }
449+
450+ fn participant_set ( ids : & [ u32 ] ) -> HashSet < ParticipantId > {
451+ ids. iter ( ) . copied ( ) . map ( ParticipantId :: from_raw) . collect ( )
452+ }
453+
454+ #[ test]
455+ fn foreign_chain_leaders_refiner__should_allow_nobody_when_chain_has_no_supporters ( ) {
456+ // Given
457+ let ( _sender, receiver) = watch:: channel ( SupportersByForeignChain :: new ( ) ) ;
458+ let refiner =
459+ ForeignChainLeadersRefiner :: new ( receiver, Some ( ReconstructionThreshold :: new ( 1 ) ) ) ;
460+
461+ // When
462+ let refined = refiner. refine (
463+ & bitcoin_verify_foreign_tx_request ( ) ,
464+ & participant_set ( & [ 0 , 1 ] ) ,
465+ ) ;
466+
467+ // Then
468+ assert ! ( refined. is_empty( ) ) ;
469+ }
470+
471+ #[ test]
472+ fn foreign_chain_leaders_refiner__should_intersect_supporters_with_eligible ( ) {
473+ // Given
474+ let supporters = SupportersByForeignChain :: from ( [ (
475+ dtos:: ForeignChain :: Bitcoin ,
476+ participant_set ( & [ 1 , 2 ] ) ,
477+ ) ] ) ;
478+ let ( _sender, receiver) = watch:: channel ( supporters) ;
479+ let refiner =
480+ ForeignChainLeadersRefiner :: new ( receiver, Some ( ReconstructionThreshold :: new ( 1 ) ) ) ;
481+
482+ // When
483+ let refined = refiner. refine (
484+ & bitcoin_verify_foreign_tx_request ( ) ,
485+ & participant_set ( & [ 0 , 1 ] ) ,
486+ ) ;
487+
488+ // Then
489+ assert_eq ! ( refined, participant_set( & [ 1 ] ) ) ;
490+ }
491+
492+ #[ test]
493+ fn foreign_chain_leaders_refiner__should_allow_nobody_when_eligible_supporters_below_quorum ( ) {
494+ // Given: three supporters, quorum 2, but only one supporter is eligible.
495+ let supporters = SupportersByForeignChain :: from ( [ (
496+ dtos:: ForeignChain :: Bitcoin ,
497+ participant_set ( & [ 1 , 2 , 3 ] ) ,
498+ ) ] ) ;
499+ let ( _sender, receiver) = watch:: channel ( supporters) ;
500+ let refiner =
501+ ForeignChainLeadersRefiner :: new ( receiver, Some ( ReconstructionThreshold :: new ( 2 ) ) ) ;
502+
503+ // When
504+ let refined = refiner. refine (
505+ & bitcoin_verify_foreign_tx_request ( ) ,
506+ & participant_set ( & [ 0 , 1 ] ) ,
507+ ) ;
508+
509+ // Then
510+ assert ! ( refined. is_empty( ) ) ;
511+ }
512+
513+ #[ test]
514+ fn foreign_chain_leaders_refiner__should_allow_supporters_when_eligible_quorum_is_met ( ) {
515+ // Given: three supporters, quorum 2, two of them eligible.
516+ let supporters = SupportersByForeignChain :: from ( [ (
517+ dtos:: ForeignChain :: Bitcoin ,
518+ participant_set ( & [ 1 , 2 , 3 ] ) ,
519+ ) ] ) ;
520+ let ( _sender, receiver) = watch:: channel ( supporters) ;
521+ let refiner =
522+ ForeignChainLeadersRefiner :: new ( receiver, Some ( ReconstructionThreshold :: new ( 2 ) ) ) ;
523+
524+ // When
525+ let refined = refiner. refine (
526+ & bitcoin_verify_foreign_tx_request ( ) ,
527+ & participant_set ( & [ 0 , 1 , 2 ] ) ,
528+ ) ;
529+
530+ // Then
531+ assert_eq ! ( refined, participant_set( & [ 1 , 2 ] ) ) ;
532+ }
533+
534+ #[ test]
535+ fn foreign_chain_leaders_refiner__should_pick_up_republished_supporters ( ) {
536+ // Given
537+ let ( sender, receiver) = watch:: channel ( SupportersByForeignChain :: new ( ) ) ;
538+ let refiner =
539+ ForeignChainLeadersRefiner :: new ( receiver, Some ( ReconstructionThreshold :: new ( 1 ) ) ) ;
540+ let eligible = participant_set ( & [ 0 , 1 ] ) ;
541+ assert ! (
542+ refiner
543+ . refine( & bitcoin_verify_foreign_tx_request( ) , & eligible)
544+ . is_empty( )
545+ ) ;
546+
547+ // When
548+ sender
549+ . send ( SupportersByForeignChain :: from ( [ (
550+ dtos:: ForeignChain :: Bitcoin ,
551+ participant_set ( & [ 0 ] ) ,
552+ ) ] ) )
553+ . unwrap ( ) ;
554+ let refined = refiner. refine ( & bitcoin_verify_foreign_tx_request ( ) , & eligible) ;
555+
556+ // Then
557+ assert_eq ! ( refined, participant_set( & [ 0 ] ) ) ;
558+ }
373559}
0 commit comments