Skip to content

Commit bec2c49

Browse files
committed
feat(node): chain-compatible presignature selection for verifying foreign tx
1 parent fae828e commit bec2c49

3 files changed

Lines changed: 170 additions & 10 deletions

File tree

crates/node/src/assets.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,38 @@ impl<T, CondVal: Default + Eq> ColdQueue<T, CondVal> {
165165
self.cold_queue.push_back((id, value));
166166
ColdQueueAddIfNotSatisfiedResult::Enqueued
167167
}
168+
169+
/// Adds an element to the cold queue based on condition evaluation:
170+
/// - if satisfied: it to the front of the queue
171+
/// - if not: at the end
172+
/// and modifies barriers accordingly.
173+
pub(self) fn ingest(&mut self, id: UniqueId, value: T) {
174+
self.update_condition_value();
175+
if (self.condition)(&self.last_condition_value, &value) {
176+
self.cold_queue.push_front((id, value));
177+
self.cold_ready += 1;
178+
self.cold_available += 1;
179+
} else {
180+
self.cold_queue.push_back((id, value));
181+
}
182+
}
183+
184+
/// Removes and returns the first element satisfying the condition with
185+
/// supplied condition value.
186+
/// Modifies barriers accordingly.
187+
pub(self) fn take_first_matching(&mut self, cond_val: &CondVal) -> Option<(UniqueId, T)> {
188+
let pos = self
189+
.cold_queue
190+
.iter()
191+
.position(|(_, val)| (self.condition)(cond_val, val))?;
192+
if pos < self.cold_ready {
193+
self.cold_ready -= 1;
194+
}
195+
if pos < self.cold_available {
196+
self.cold_available -= 1;
197+
}
198+
self.cold_queue.remove(pos)
199+
}
168200
}
169201

170202
enum ColdQueueTakeResult<T> {
@@ -270,6 +302,31 @@ where
270302
}
271303
}
272304

305+
pub async fn take_owned_matching(&self, cond_val: CondVal) -> (UniqueId, T) {
306+
loop {
307+
{
308+
let mut cold = self.cold_queue.lock().unwrap();
309+
while let Some(Ok((id, value))) = self.hot_receiver.recv_async().now_or_never() {
310+
cold.ingest(id, value);
311+
}
312+
if let Some(taken) = cold.take_first_matching(&cond_val) {
313+
return taken;
314+
}
315+
}
316+
// If the cold queue is exhausted, wait for a new element.
317+
tokio::select! {
318+
_ = self.clock.sleep(near_time::Duration::seconds(1)) => {
319+
continue;
320+
}
321+
received = self.hot_receiver.recv_async() => {
322+
// can't fail, because self keeps a sender.
323+
let (id, val) = received.unwrap();
324+
self.cold_queue.lock().unwrap().ingest(id, val);
325+
}
326+
}
327+
}
328+
}
329+
273330
/// Process `num_elements_to_process`, removing any that doesn't satisfy condition.
274331
/// Return ids, that were removed from cold storage.
275332
pub async fn maybe_discard_owned(&self, mut num_elements_to_process: usize) -> Vec<UniqueId> {
@@ -569,6 +626,18 @@ where
569626
result
570627
}
571628

629+
// Takes an owned asset whose participants are all in eligible.
630+
// Blocks until there is an asset available satisfying the condition.
631+
pub async fn take_owned_matching(&self, eligible: Vec<ParticipantId>) -> (UniqueId, T) {
632+
let (id, val) = self.owned_queue.take_owned_matching(eligible).await;
633+
let mut update = self.db.update();
634+
update.delete(self.col, &self.make_key(id));
635+
update
636+
.commit()
637+
.expect("Unrecoverable error writing to database");
638+
(id, val)
639+
}
640+
572641
fn take_unowned_inner(&self, id: UniqueId) -> anyhow::Result<T> {
573642
let key = self.make_key(id);
574643
let value_ser = self.db.get(self.col, &key)?.ok_or_else(|| {
@@ -1398,4 +1467,76 @@ mod tests {
13981467
}
13991468
}
14001469
}
1470+
1471+
#[test]
1472+
#[expect(non_snake_case)]
1473+
fn take_owned_matching__should_return_matching_asset_immediately() {
1474+
// Given: a queue holding one non-matching and one matching asset.
1475+
let clock = FakeClock::default();
1476+
let queue = DoubleQueue::new(clock.clock(), |cond, val| val % 2 == *cond, Arc::new(|| 0));
1477+
let id1 = UniqueId::new(ParticipantId::from_raw(42), 123, 456);
1478+
let id2 = id1.add_to_counter(1).unwrap();
1479+
queue.add_owned(id1, 2);
1480+
queue.add_owned(id2, 3);
1481+
1482+
// When: taking with a condition value matching only the second asset.
1483+
let taken = queue.take_owned_matching(1).now_or_never();
1484+
1485+
// Then: the matching asset is returned and the non-matching one stays.
1486+
assert_eq!(taken, Some((id2, 3)));
1487+
assert_eq!(queue.available(), 1);
1488+
}
1489+
1490+
#[test]
1491+
#[expect(non_snake_case)]
1492+
fn take_owned_matching__should_wait_until_matching_asset_is_added() {
1493+
// Given: a queue holding only a non-matching asset.
1494+
let clock = FakeClock::default();
1495+
let queue = DoubleQueue::new(clock.clock(), |cond, val| val % 2 == *cond, Arc::new(|| 0));
1496+
let id1 = UniqueId::new(ParticipantId::from_raw(42), 123, 456);
1497+
let id2 = id1.add_to_counter(1).unwrap();
1498+
queue.add_owned(id1, 2);
1499+
1500+
// When: taking with a condition value nothing matches yet.
1501+
let fut = queue.take_owned_matching(1);
1502+
let MaybeReady::Future(fut) = run_future_once(fut) else {
1503+
panic!("should not take a value when no element matches");
1504+
};
1505+
1506+
// Then: the take completes once a matching asset is added.
1507+
queue.add_owned(id2, 3);
1508+
assert_eq!(fut.now_or_never().unwrap(), (id2, 3));
1509+
// And: the non-matching asset was not consumed.
1510+
assert_eq!(queue.available(), 1);
1511+
}
1512+
1513+
#[test]
1514+
#[expect(non_snake_case)]
1515+
fn take_first_matching__should_maintain_barrier_invariants() {
1516+
// Given: a cold queue partitioned into ready and non-satisfying sections.
1517+
let clock = FakeClock::default();
1518+
let mut queue = ColdQueue::new(clock.clock(), |cond, val| val % 2 == *cond, Arc::new(|| 0));
1519+
let id1 = UniqueId::new(ParticipantId::from_raw(42), 1, 0);
1520+
let id2 = id1.add_to_counter(1).unwrap();
1521+
let id3 = id1.add_to_counter(2).unwrap();
1522+
queue.ingest(id1, 2);
1523+
queue.ingest(id2, 4);
1524+
queue.ingest(id3, 3);
1525+
verify_cold_queue_internal_consistency(&queue, 3);
1526+
1527+
// When: taking with a condition value differing from the standing one.
1528+
let taken = queue.take_first_matching(&1);
1529+
1530+
// Then: the non-satisfying element is removed, barriers stay consistent.
1531+
assert_eq!(taken, Some((id3, 3)));
1532+
verify_cold_queue_internal_consistency(&queue, 2);
1533+
1534+
// And: taking under the standing value removes from the ready section.
1535+
assert_eq!(queue.take_first_matching(&0), Some((id2, 4)));
1536+
verify_cold_queue_internal_consistency(&queue, 1);
1537+
1538+
// And: no match leaves the queue untouched.
1539+
assert_eq!(queue.take_first_matching(&1), None);
1540+
verify_cold_queue_internal_consistency(&queue, 1);
1541+
}
14011542
}

crates/node/src/providers/ecdsa.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ impl EcdsaSignatureProvider {
120120
) -> anyhow::Result<NetworkTaskChannel> {
121121
self.client.new_channel_for_task(task_id, participants)
122122
}
123+
124+
pub(super) fn alive_participant_ids(&self) -> Vec<ParticipantId> {
125+
self.client.all_alive_participant_ids()
126+
}
123127
}
124128

125129
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)]

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

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashSet;
2+
13
use anyhow::{Context, bail};
24
use foreign_chain_inspector::abstract_chain::inspector::AbstractExtractor;
35
use foreign_chain_inspector::aptos::inspector::{AptosExtractor, AptosFinality};
@@ -15,6 +17,7 @@ use tokio_util::time::FutureExt;
1517

1618
use crate::foreign_chain_policy::SupportersByForeignChain;
1719
use crate::metrics;
20+
use crate::primitives::ParticipantId;
1821
use crate::providers::verify_foreign_tx::VerifyForeignTxTaskId;
1922
use crate::types::{SignatureRequest, VerifyForeignTxRequest};
2023
use crate::{
@@ -55,19 +58,31 @@ impl VerifyForeignTxProvider {
5558
) -> anyhow::Result<((dtos::ForeignTxSignPayload, Signature), VerifyingKey)> {
5659
let foreign_tx_request = self.verify_foreign_tx_request_store.get(id).await?;
5760

58-
// Also checked in `execute_foreign_chain_request`; checked early here
59-
// because `take_owned` below irreversibly consumes a presignature. An
60-
// availability flip between the two checks still costs one presignature.
61-
ensure_chain_is_available(
62-
&self.supporters_by_foreign_chain.borrow(),
63-
&foreign_tx_request.request,
64-
)
65-
.inspect_err(|_| metrics::MPC_NUM_VERIFY_FOREIGN_TX_UNAVAILABLE_CHAIN_REJECTIONS.inc())?;
66-
61+
let requested_chain = foreign_tx_request.request.chain();
62+
63+
// Also checked in `execute_foreign_chain_request`; resolved early here
64+
// because the supporter set scopes which presignature may be taken. An
65+
// availability flip after the take still costs one presignature.
66+
let chain_supporters: HashSet<ParticipantId> = {
67+
let snapshot = self.supporters_by_foreign_chain.borrow();
68+
ensure_chain_is_available(&snapshot, &foreign_tx_request.request).inspect_err(
69+
|_| metrics::MPC_NUM_VERIFY_FOREIGN_TX_UNAVAILABLE_CHAIN_REJECTIONS.inc(),
70+
)?;
71+
snapshot.get(&requested_chain).cloned().unwrap_or_default()
72+
};
6773
let keyshare = self
6874
.ecdsa_signature_provider
6975
.keyshare(foreign_tx_request.domain_id)?;
70-
let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await;
76+
let eligible: Vec<ParticipantId> = self
77+
.ecdsa_signature_provider
78+
.alive_participant_ids()
79+
.into_iter()
80+
.filter(|id| chain_supporters.contains(id))
81+
.collect();
82+
let (presignature_id, presignature) = keyshare
83+
.presignature_store
84+
.take_owned_matching(eligible)
85+
.await;
7186
let participants = presignature.participants.clone();
7287
let channel = self.ecdsa_signature_provider.new_channel_for_task(
7388
VerifyForeignTxTaskId::VerifyForeignTx {

0 commit comments

Comments
 (0)