Skip to content

Commit 38e27a2

Browse files
committed
Merge remote-tracking branch 'origin/main' into 2287-attestation-tasks-permanently-die-after-12h-retry-timeout
2 parents 0c00f7b + c5faeb0 commit 38e27a2

11 files changed

Lines changed: 424 additions & 34 deletions

File tree

crates/node/src/coordinator.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,10 @@ where
703703
// and remain after the reshare supports it. With no ForeignTx
704704
// domain nothing can be available, so the resolver isn't
705705
// spawned and the provider sees a constant empty map.
706+
let foreign_tx_threshold =
707+
foreign_tx_reconstruction_threshold(&running_state.domains);
706708
let (supporters_by_foreign_chain, _supporters_resolver_task) =
707-
match foreign_tx_reconstruction_threshold(&running_state.domains) {
709+
match foreign_tx_threshold {
708710
Some(threshold) => {
709711
let (receiver, task) = spawn_supporters_by_foreign_chain(
710712
foreign_chain_supporters_receiver,
@@ -725,6 +727,7 @@ where
725727
let verify_foreign_tx_provider = Arc::new(VerifyForeignTxProvider::new(
726728
config_file.clone().into(),
727729
supporters_by_foreign_chain,
730+
foreign_tx_threshold,
728731
verify_foreign_tx_request_store.clone(),
729732
ecdsa_signature_provider.clone(),
730733
)?);

crates/node/src/foreign_chain_policy.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use tokio::sync::watch;
99
use crate::config::ParticipantsConfig;
1010
use crate::indexer::foreign_chain::ForeignChainSupporters;
1111
use crate::primitives::ParticipantId;
12+
use crate::requests::queue::RefineEligibleLeaders;
1213
use 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)]
134193
mod 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
}

crates/node/src/mpc_client.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ impl MpcClient {
265265
self.client.my_participant_id(),
266266
self.client.clone(),
267267
);
268+
268269
let mut pending_verify_foreign_txs = PendingRequests::<
269270
VerifyForeignTxRequest,
270271
contract_args::VerifyForeignTransactionRespondArgs,
@@ -273,6 +274,10 @@ impl MpcClient {
273274
self.client.all_participant_ids(),
274275
self.client.my_participant_id(),
275276
self.client.clone(),
277+
)
278+
.with_eligible_leaders_refiner(
279+
self.verify_foreign_tx_provider
280+
.new_eligible_leaders_refiner(),
276281
);
277282

278283
let mut recent_blocks = RecentBlocksTracker::new(REQUEST_EXPIRATION_BLOCKS);

crates/node/src/providers/verify_foreign_tx.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
mod sign;
22

3-
use crate::foreign_chain_policy::SupportersByForeignChain;
3+
use crate::foreign_chain_policy::{ForeignChainLeadersRefiner, SupportersByForeignChain};
44
use crate::network::NetworkTaskChannel;
55
use crate::primitives::{MpcTaskId, UniqueId};
66
use crate::providers::EcdsaSignatureProvider;
@@ -25,6 +25,7 @@ use foreign_chain_rpc_auth::auth_config_to_rpc_auth;
2525
use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient;
2626
use foreign_chain_rpc_interfaces::sui::GrpcSuiClient;
2727
use mpc_node_config::{ConfigFile, ForeignChainConfig, ForeignChainsConfig};
28+
use mpc_primitives::ReconstructionThreshold;
2829
use near_mpc_contract_interface::types::ProviderId;
2930
use std::sync::Arc;
3031
use std::time::Duration;
@@ -161,6 +162,9 @@ pub struct VerifyForeignTxProvider {
161162
config: Arc<ConfigFile>,
162163
inspectors: ForeignChainInspectors<HttpClient>,
163164
supporters_by_foreign_chain: watch::Receiver<SupportersByForeignChain>,
165+
/// [`foreign_tx_reconstruction_threshold`](crate::foreign_chain_policy::foreign_tx_reconstruction_threshold)
166+
/// of the running domains; `None` when there is no ForeignTx domain.
167+
foreign_tx_reconstruction_threshold: Option<ReconstructionThreshold>,
164168
verify_foreign_tx_request_store: Arc<VerifyForeignTransactionRequestStorage>,
165169
ecdsa_signature_provider: Arc<EcdsaSignatureProvider>,
166170
}
@@ -183,6 +187,7 @@ impl VerifyForeignTxProvider {
183187
pub fn new(
184188
config: Arc<ConfigFile>,
185189
supporters_by_foreign_chain: watch::Receiver<SupportersByForeignChain>,
190+
foreign_tx_reconstruction_threshold: Option<ReconstructionThreshold>,
186191
verify_foreign_tx_request_store: Arc<VerifyForeignTransactionRequestStorage>,
187192
ecdsa_signature_provider: Arc<EcdsaSignatureProvider>,
188193
) -> anyhow::Result<Self> {
@@ -191,11 +196,19 @@ impl VerifyForeignTxProvider {
191196
config,
192197
inspectors,
193198
supporters_by_foreign_chain,
199+
foreign_tx_reconstruction_threshold,
194200
verify_foreign_tx_request_store,
195201
ecdsa_signature_provider,
196202
})
197203
}
198204

205+
pub(crate) fn new_eligible_leaders_refiner(&self) -> ForeignChainLeadersRefiner {
206+
ForeignChainLeadersRefiner::new(
207+
self.supporters_by_foreign_chain.clone(),
208+
self.foreign_tx_reconstruction_threshold,
209+
)
210+
}
211+
199212
pub async fn process_channel(&self, channel: NetworkTaskChannel) -> anyhow::Result<()> {
200213
match channel.task_id() {
201214
MpcTaskId::VerifyForeignTxTaskId(task) => match task {

crates/node/src/providers/verify_foreign_tx/sign.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@ impl VerifyForeignTxProvider {
9494
snapshot.get(&requested_chain).cloned().unwrap_or_default()
9595
};
9696

97-
// If we don't support the foreign chain, we can't lead the computation.
98-
// TODO(#3961): narrow leader selection to only chain supporters.
97+
// Leader selection already narrows to chain supporters. Re-check as
98+
// defense-in-depth, since the supporters snapshot may have changed
99+
// between leader selection and this attempt.
99100
let my_participant_id = self.ecdsa_signature_provider.my_participant_id();
100101
if !chain_supporters.contains(&my_participant_id) {
101102
metrics::MPC_NUM_VERIFY_FOREIGN_TX_UNAVAILABLE_CHAIN_REJECTIONS.inc();
102-
anyhow::bail!("this node does not support the requested chain {requested_chain:?}");
103+
anyhow::bail!(
104+
"selected as leader for a {requested_chain:?} request but this node no longer \
105+
supports that chain. Supporters must have changed since leader selection"
106+
);
103107
}
104108

105109
let response_payload = self

crates/node/src/requests.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod queue;
22

3+
pub(crate) mod metrics;
4+
35
mod debug;
4-
mod metrics;

crates/node/src/requests/debug.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::queue::{
22
ComputationProgress, EligibleLeadersAndHeights, PendingRequests, QueuedRequest,
3+
RefineEligibleLeaders,
34
};
45
use crate::indexer::types::ChainRespondArgs;
56
use crate::primitives::ParticipantId;
@@ -175,8 +176,12 @@ impl<RequestType: Request, ChainRespondArgsType: ChainRespondArgs>
175176
}
176177
}
177178

178-
impl<RequestType: Request + Clone, ChainRespondArgsType: ChainRespondArgs> Debug
179-
for PendingRequests<RequestType, ChainRespondArgsType>
179+
impl<RequestType, ChainRespondArgsType, Refiner> Debug
180+
for PendingRequests<RequestType, ChainRespondArgsType, Refiner>
181+
where
182+
RequestType: Request + Clone,
183+
ChainRespondArgsType: ChainRespondArgs,
184+
Refiner: RefineEligibleLeaders<RequestType>,
180185
{
181186
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182187
let mut request_lines = Vec::new();
@@ -189,8 +194,14 @@ impl<RequestType: Request + Clone, ChainRespondArgsType: ChainRespondArgs> Debug
189194
let indexer_heights = self.network_api.indexer_heights();
190195

191196
for request in self.requests.values() {
192-
let debug_line =
193-
request.debug_print(&self.clock, self.my_participant_id, &eligible_leaders);
197+
let request_eligible_leaders = self
198+
.refine_eligible_leaders
199+
.refine(&request.request, &eligible_leaders);
200+
let debug_line = request.debug_print(
201+
&self.clock,
202+
self.my_participant_id,
203+
&request_eligible_leaders,
204+
);
194205
request_lines.push((
195206
request.block_height.into(),
196207
request.request.get_id(),

crates/node/src/requests/metrics.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,15 @@ pub static MPC_CLUSTER_FAILED_SIGNATURES_COUNT: LazyLock<prometheus::IntCounterV
238238
)
239239
.unwrap()
240240
});
241+
242+
pub static MPC_NUM_REQUESTS_WITHOUT_REFINED_LEADER_TOTAL: LazyLock<prometheus::IntCounterVec> =
243+
LazyLock::new(|| {
244+
prometheus::register_int_counter_vec!(
245+
"mpc_num_requests_without_refined_leader_total",
246+
"Number of queue passes where a request had eligible leaders, but none was allowed \
247+
by the eligible-leaders refiner (e.g. no participant supports the request's \
248+
foreign chain), so the request stays parked",
249+
&["request_type"]
250+
)
251+
.unwrap()
252+
});

0 commit comments

Comments
 (0)