Skip to content

Commit e874c1e

Browse files
shane-mooreclaude
andauthored
feat: replace envelope QBFT with disseminate-and-sign (SIP-94 §6) (#1286)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 52ba589 commit e874c1e

34 files changed

Lines changed: 2860 additions & 1809 deletions

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ members = [
66
"anchor/client",
77
"anchor/common/api_types",
88
"anchor/common/bls_lagrange",
9+
"anchor/common/dissemination_store",
910
"anchor/common/fork",
1011
"anchor/common/global_config",
1112
"anchor/common/operator_key",
@@ -48,6 +49,7 @@ bls_lagrange = { path = "anchor/common/bls_lagrange" }
4849
cli = { path = "anchor/cli" }
4950
client = { path = "anchor/client" }
5051
database = { path = "anchor/database" }
52+
dissemination_store = { path = "anchor/common/dissemination_store" }
5153
docgen = { path = "anchor/docgen" }
5254
duties_tracker = { path = "anchor/duties_tracker" }
5355
eth = { path = "anchor/eth" }

anchor/client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ beacon_node_fallback = { workspace = true }
1414
clap = { workspace = true }
1515
cli = { workspace = true }
1616
database = { workspace = true }
17+
dissemination_store = { workspace = true }
1718
duties_tracker = { workspace = true }
1819
eth = { workspace = true }
1920
eth2 = { workspace = true }

anchor/client/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,10 +598,15 @@ impl Client {
598598

599599
let (outcome_tx, outcome_rx) = mpsc::channel::<message_receiver::Outcome>(9000);
600600

601+
// Shared between the message receiver (writer) and the validator store's envelope duty
602+
// runner (reader); SIP-94 §6.
603+
let dissemination_store = Arc::new(dissemination_store::DisseminationStore::new());
604+
601605
let message_receiver = NetworkMessageReceiver::<E, _, _>::new(
602606
processor_senders.clone(),
603607
qbft_manager.clone(),
604608
signature_collector.clone(),
609+
dissemination_store.clone(),
605610
database.watch(),
606611
is_synced.clone(),
607612
outcome_tx,
@@ -635,6 +640,7 @@ impl Client {
635640
let validator_store = AnchorValidatorStore::<_, E, _>::new(
636641
database.clone(),
637642
Box::new(signature_collector),
643+
dissemination_store,
638644
qbft_manager,
639645
slashing_protection,
640646
config.disable_slashing_protection,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[package]
2+
name = "dissemination_store"
3+
version = "0.1.0"
4+
edition = { workspace = true }
5+
authors = ["Sigma Prime <contact@sigmaprime.io>"]
6+
7+
[dependencies]
8+
bls = { workspace = true }
9+
parking_lot = { workspace = true }
10+
ssv_types = { workspace = true }
11+
tokio = { workspace = true }
12+
types = { workspace = true }
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
//! Handoff store for SIP-94 §6 envelope disseminations.
2+
//!
3+
//! The message receiver writes the first validator-accepted `EnvelopeDissemination` per
4+
//! `(validator, slot)`; the envelope duty runner awaits it. Message validation's first-valid
5+
//! rule delivers at most one dissemination per key for the process lifetime, so the store is
6+
//! first-write-wins and never replaces an entry.
7+
8+
use std::collections::HashMap;
9+
10+
use bls::PublicKeyBytes;
11+
use parking_lot::Mutex;
12+
use ssv_types::dissemination::EnvelopeDissemination;
13+
use tokio::{sync::oneshot, time::Instant};
14+
use types::Slot;
15+
16+
/// Number of slots an entry stays readable, mirroring the decided-block context retention.
17+
const MAX_DISSEMINATION_AGE_SLOTS: u64 = 4;
18+
19+
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
20+
struct Key {
21+
validator: PublicKeyBytes,
22+
slot: Slot,
23+
}
24+
25+
enum Entry {
26+
/// The accepted dissemination for the key.
27+
Ready(EnvelopeDissemination),
28+
/// Runners awaiting the dissemination. Senders are pruned when closed, so timed-out or
29+
/// cancelled waiters do not accumulate.
30+
Waiting(Vec<oneshot::Sender<EnvelopeDissemination>>),
31+
}
32+
33+
/// Shared store connecting the message receiver (writer) to the envelope duty runner (reader).
34+
#[derive(Default)]
35+
pub struct DisseminationStore {
36+
inner: Mutex<HashMap<Key, Entry>>,
37+
}
38+
39+
impl DisseminationStore {
40+
pub fn new() -> Self {
41+
Self::default()
42+
}
43+
44+
/// Records the accepted dissemination for `(validator, slot)` and wakes every waiter.
45+
///
46+
/// First-write-wins: a `Ready` entry is never replaced. Entries older than
47+
/// `MAX_DISSEMINATION_AGE_SLOTS` relative to the inserted slot are dropped on insert
48+
/// (addition on the stored side, so an early slot cannot underflow).
49+
pub fn insert(&self, validator: PublicKeyBytes, dissemination: EnvelopeDissemination) {
50+
let slot = dissemination.slot;
51+
let key = Key { validator, slot };
52+
let mut inner = self.inner.lock();
53+
Self::sweep(&mut inner, slot);
54+
55+
match inner.entry(key) {
56+
std::collections::hash_map::Entry::Vacant(entry) => {
57+
entry.insert(Entry::Ready(dissemination));
58+
}
59+
std::collections::hash_map::Entry::Occupied(mut entry) => match entry.get_mut() {
60+
Entry::Ready(_) => {}
61+
Entry::Waiting(waiters) => {
62+
for waiter in waiters.drain(..) {
63+
// A dropped receiver (timed-out waiter) is fine; the value stays Ready.
64+
let _ = waiter.send(dissemination.clone());
65+
}
66+
entry.insert(Entry::Ready(dissemination));
67+
}
68+
},
69+
}
70+
}
71+
72+
/// Awaits the dissemination for `(validator, slot)` until `deadline`.
73+
///
74+
/// Returns immediately when the entry is already `Ready`. A timed-out or cancelled wait
75+
/// leaves a `Ready` value available for a later call. Waiter registrations sweep stale
76+
/// keys and prune closed senders, so unanswered waits stay bounded even when no
77+
/// dissemination ever arrives.
78+
pub async fn wait(
79+
&self,
80+
validator: PublicKeyBytes,
81+
slot: Slot,
82+
deadline: Instant,
83+
) -> Option<EnvelopeDissemination> {
84+
let receiver = {
85+
let key = Key { validator, slot };
86+
let mut inner = self.inner.lock();
87+
Self::sweep(&mut inner, slot);
88+
for entry in inner.values_mut() {
89+
if let Entry::Waiting(waiters) = entry {
90+
waiters.retain(|waiter| !waiter.is_closed());
91+
}
92+
}
93+
94+
match inner
95+
.entry(key)
96+
.or_insert_with(|| Entry::Waiting(Vec::new()))
97+
{
98+
Entry::Ready(dissemination) => return Some(dissemination.clone()),
99+
Entry::Waiting(waiters) => {
100+
let (sender, receiver) = oneshot::channel();
101+
waiters.push(sender);
102+
receiver
103+
}
104+
}
105+
};
106+
107+
tokio::time::timeout_at(deadline, receiver).await.ok()?.ok()
108+
}
109+
110+
/// Drops entries older than `MAX_DISSEMINATION_AGE_SLOTS` relative to `slot`. A call for
111+
/// an old slot cannot evict a newer entry.
112+
fn sweep(inner: &mut HashMap<Key, Entry>, slot: Slot) {
113+
inner.retain(|stored_key, _| stored_key.slot + MAX_DISSEMINATION_AGE_SLOTS >= slot);
114+
}
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use std::{sync::Arc, time::Duration};
120+
121+
use ssv_types::VariableList;
122+
123+
use super::*;
124+
125+
fn dissemination(slot: u64) -> EnvelopeDissemination {
126+
EnvelopeDissemination {
127+
slot: Slot::new(slot),
128+
envelope: VariableList::new(vec![0xAA; 8]).unwrap(),
129+
}
130+
}
131+
132+
fn pubkey(byte: u8) -> PublicKeyBytes {
133+
PublicKeyBytes::deserialize(&[byte; 48]).expect("48 bytes is a valid pubkey length")
134+
}
135+
136+
fn soon() -> Instant {
137+
Instant::now() + Duration::from_millis(200)
138+
}
139+
140+
#[tokio::test]
141+
async fn ready_before_wait_returns_immediately() {
142+
let store = DisseminationStore::new();
143+
store.insert(pubkey(1), dissemination(5));
144+
145+
let got = store.wait(pubkey(1), Slot::new(5), soon()).await;
146+
assert_eq!(got, Some(dissemination(5)));
147+
}
148+
149+
#[tokio::test]
150+
async fn wait_before_ready_wakes_all_same_key_waiters() {
151+
let store = Arc::new(DisseminationStore::new());
152+
let deadline = Instant::now() + Duration::from_secs(5);
153+
let w1 = tokio::spawn({
154+
let store = store.clone();
155+
async move { store.wait(pubkey(1), Slot::new(5), deadline).await }
156+
});
157+
let w2 = tokio::spawn({
158+
let store = store.clone();
159+
async move { store.wait(pubkey(1), Slot::new(5), deadline).await }
160+
});
161+
// Let both waiters register before the insert.
162+
tokio::time::sleep(Duration::from_millis(50)).await;
163+
164+
store.insert(pubkey(1), dissemination(5));
165+
166+
assert_eq!(w1.await.unwrap(), Some(dissemination(5)));
167+
assert_eq!(w2.await.unwrap(), Some(dissemination(5)));
168+
}
169+
170+
#[tokio::test]
171+
async fn timed_out_wait_can_retry_against_ready() {
172+
let store = DisseminationStore::new();
173+
174+
let got = store.wait(pubkey(1), Slot::new(5), soon()).await;
175+
assert_eq!(got, None, "no insert: the wait must time out");
176+
177+
store.insert(pubkey(1), dissemination(5));
178+
let got = store.wait(pubkey(1), Slot::new(5), soon()).await;
179+
assert_eq!(
180+
got,
181+
Some(dissemination(5)),
182+
"the value must stay available after an earlier timed-out wait"
183+
);
184+
}
185+
186+
#[tokio::test]
187+
async fn first_write_wins() {
188+
let store = DisseminationStore::new();
189+
let first = dissemination(5);
190+
let mut second = dissemination(5);
191+
second.envelope = VariableList::new(vec![0xBB; 8]).unwrap();
192+
193+
store.insert(pubkey(1), first.clone());
194+
store.insert(pubkey(1), second);
195+
196+
let got = store.wait(pubkey(1), Slot::new(5), soon()).await;
197+
assert_eq!(got, Some(first), "a Ready entry must never be replaced");
198+
}
199+
200+
#[tokio::test]
201+
async fn keys_are_isolated() {
202+
let store = DisseminationStore::new();
203+
store.insert(pubkey(1), dissemination(5));
204+
205+
assert_eq!(store.wait(pubkey(2), Slot::new(5), soon()).await, None);
206+
assert_eq!(store.wait(pubkey(1), Slot::new(6), soon()).await, None);
207+
}
208+
209+
#[tokio::test]
210+
async fn insert_evicts_entries_past_the_age_window() {
211+
let store = DisseminationStore::new();
212+
store.insert(pubkey(1), dissemination(5));
213+
store.insert(
214+
pubkey(1),
215+
dissemination(5 + MAX_DISSEMINATION_AGE_SLOTS + 1),
216+
);
217+
218+
assert_eq!(
219+
store.wait(pubkey(1), Slot::new(5), soon()).await,
220+
None,
221+
"an insert past the age window must evict the older entry"
222+
);
223+
}
224+
225+
#[tokio::test]
226+
async fn unanswered_waits_stay_bounded() {
227+
let store = DisseminationStore::new();
228+
// Many timed-out waits across distinct slots, no inserts at all.
229+
for slot in 0..100u64 {
230+
let _ = store.wait(pubkey(1), Slot::new(slot), Instant::now()).await;
231+
}
232+
233+
let inner = store.inner.lock();
234+
assert!(
235+
inner.len() as u64 <= MAX_DISSEMINATION_AGE_SLOTS + 1,
236+
"wait-created entries must be swept; got {} keys",
237+
inner.len()
238+
);
239+
let waiters: usize = inner
240+
.values()
241+
.map(|entry| match entry {
242+
Entry::Ready(_) => 0,
243+
Entry::Waiting(waiters) => waiters.len(),
244+
})
245+
.sum();
246+
// The final wait's own sender has no later registration to prune it; everything
247+
// older must be gone.
248+
assert!(
249+
waiters <= 1,
250+
"closed senders must be pruned on later registrations; got {waiters}"
251+
);
252+
}
253+
}

0 commit comments

Comments
 (0)