/home/runner/work/lyquor/lyquor/toolchain/shaker/src/availability.rs
Line | Count | Source |
1 | | use std::future::Future; |
2 | | use std::sync::Arc; |
3 | | use std::time::Duration; |
4 | | |
5 | | use alloy_sol_types::{SolCall, sol}; |
6 | | use anyhow::Context; |
7 | | use lyquor_eth::{EthSubmitter, Signer}; |
8 | | use lyquor_jsonrpc::types::{ |
9 | | BlockNumber, EthCall, EthCallResp, EthCallTx, EthGetTransactionReceipt, EthGetTransactionReceiptResp, |
10 | | }; |
11 | | use lyquor_primitives::alloy_primitives::B256; |
12 | | use lyquor_primitives::oracle::OracleConfig; |
13 | | use lyquor_primitives::{Address, NodeID, U256, decode_object}; |
14 | | |
15 | | use crate::Client; |
16 | | |
17 | | const AVAILABILITY_TOPIC: &str = "availability"; |
18 | | const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(200); |
19 | | const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(60); |
20 | | const COMMITTEE_TIMEOUT_HINT: &str = "a committee node may be offline, the threshold may be unreachable, or a committee node may be missing its Ed25519 key"; |
21 | | |
22 | | sol! { |
23 | | interface BartenderAvailability { |
24 | | function __lyquor_oracle_initialize( |
25 | | string topic, |
26 | | address targetAddr, |
27 | | bool isEvm, |
28 | | bytes32[] committee, |
29 | | uint16 threshold |
30 | | ) external; |
31 | | function __lyquor_oracle_advance_epoch( |
32 | | string topic, |
33 | | address targetAddr, |
34 | | bool isEvm |
35 | | ) external returns (bool); |
36 | | function __lyquor_oracle_finalize_epoch( |
37 | | string topic, |
38 | | address targetAddr, |
39 | | bool isEvm |
40 | | ) external returns (bool); |
41 | | function __lyquor_oracle_dest_epoch_info( |
42 | | string topic, |
43 | | bool fullConfig |
44 | | ) external returns (uint64 epoch, bytes32 configHash, uint32 changeCount, bytes config); |
45 | | function get_availability_epoch() external returns (uint32); |
46 | | function get_availability_counts() external returns (uint64 admittedImages, uint64 pendingDeployments); |
47 | | } |
48 | 0 | } |
49 | | |
50 | | /// Observable availability-gate state. |
51 | | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] |
52 | | pub struct AvailabilityStatus { |
53 | | pub source_epoch: u32, |
54 | | pub dest_epoch: u32, |
55 | | pub committee: Vec<NodeID>, |
56 | | pub threshold: u16, |
57 | | pub admitted_image_count: u64, |
58 | | pub pending_deployment_count: u64, |
59 | | } |
60 | | |
61 | | #[derive(Debug)] |
62 | | struct EpochState { |
63 | | source_epoch: u32, |
64 | | dest_epoch: u32, |
65 | | config: OracleConfig, |
66 | | } |
67 | | |
68 | 0 | async fn eth_call<C: SolCall>(client: &Client, bartender: Address, call: C) -> anyhow::Result<C::Return> { |
69 | 0 | let response: EthCallResp = client |
70 | 0 | .request(EthCall { |
71 | 0 | tx: EthCallTx { |
72 | 0 | from: None, |
73 | 0 | to: Some(bartender), |
74 | 0 | gas: None, |
75 | 0 | gas_price: None, |
76 | 0 | value: None, |
77 | 0 | data: Some(call.abi_encode().into()), |
78 | 0 | }, |
79 | 0 | block_number: BlockNumber::Latest, |
80 | 0 | }) |
81 | 0 | .await |
82 | 0 | .context("Availability call failed")?; |
83 | 0 | C::abi_decode_returns(&response.0).context("Failed to decode availability call response") |
84 | 0 | } |
85 | | |
86 | 0 | async fn epoch_state(client: &Client, bartender: Address) -> anyhow::Result<EpochState> { |
87 | 0 | let (source_epoch, dest) = tokio::try_join!( |
88 | 0 | eth_call(client, bartender, BartenderAvailability::get_availability_epochCall {}), |
89 | 0 | eth_call( |
90 | 0 | client, |
91 | 0 | bartender, |
92 | 0 | BartenderAvailability::__lyquor_oracle_dest_epoch_infoCall { |
93 | 0 | topic: AVAILABILITY_TOPIC.to_owned(), |
94 | 0 | fullConfig: true, |
95 | 0 | }, |
96 | | ) |
97 | 0 | )?; |
98 | 0 | let dest_epoch = u32::try_from(dest.epoch).context("Availability destination epoch does not fit in u32")?; |
99 | 0 | let config = if dest.config.is_empty() { |
100 | 0 | OracleConfig { |
101 | 0 | committee: Vec::new(), |
102 | 0 | threshold: 0, |
103 | 0 | } |
104 | | } else { |
105 | 0 | decode_object(&dest.config).context("Bartender returned an invalid availability committee config")? |
106 | | }; |
107 | 0 | Ok(EpochState { |
108 | 0 | source_epoch, |
109 | 0 | dest_epoch, |
110 | 0 | config, |
111 | 0 | }) |
112 | 0 | } |
113 | | |
114 | 0 | async fn poll<T, F, Fut>(timeout_message: impl Into<String>, mut operation: F) -> anyhow::Result<T> |
115 | 0 | where |
116 | 0 | F: FnMut() -> Fut, |
117 | 0 | Fut: Future<Output = anyhow::Result<Option<T>>>, |
118 | 0 | { |
119 | 0 | let timeout_message = timeout_message.into(); |
120 | 0 | tokio::time::timeout(DEFAULT_POLL_TIMEOUT, async { |
121 | | loop { |
122 | 0 | if let Some(value) = operation().await? { |
123 | 0 | return Ok(value); |
124 | 0 | } |
125 | 0 | tokio::time::sleep(DEFAULT_POLL_INTERVAL).await; |
126 | | } |
127 | 0 | }) |
128 | 0 | .await |
129 | 0 | .map_err(|_| anyhow::Error::msg(timeout_message))? |
130 | 0 | } |
131 | | |
132 | 0 | async fn wait_for_transaction(client: &Client, tx_hash: B256) -> anyhow::Result<()> { |
133 | 0 | let receipt = poll( |
134 | 0 | format!("Timed out waiting for availability initialization transaction {tx_hash} to be sequenced"), |
135 | 0 | || async { |
136 | 0 | let receipt: EthGetTransactionReceiptResp = client.request(EthGetTransactionReceipt(tx_hash)).await?; |
137 | 0 | Ok(receipt) |
138 | 0 | }, |
139 | | ) |
140 | 0 | .await?; |
141 | 0 | if receipt.status != U256::from(1_u8) { |
142 | 0 | anyhow::bail!( |
143 | | "Availability initialization transaction {tx_hash} failed with status {}", |
144 | | receipt.status |
145 | | ); |
146 | 0 | } |
147 | 0 | Ok(()) |
148 | 0 | } |
149 | | |
150 | 5 | fn validate_committee(committee: &[NodeID], threshold: u16) -> anyhow::Result<()> { |
151 | 5 | if committee.is_empty() { |
152 | 1 | anyhow::bail!("Availability committee cannot be empty"); |
153 | 4 | } |
154 | 4 | if threshold == 0 || usize::from3 (threshold3 ) > committee.len() { |
155 | 2 | anyhow::bail!( |
156 | | "Availability threshold {} must be between 1 and the committee size {}", |
157 | | threshold, |
158 | 2 | committee.len() |
159 | | ); |
160 | 2 | } |
161 | 2 | let mut unique = committee.to_vec(); |
162 | 2 | unique.sort_unstable(); |
163 | 2 | unique.dedup(); |
164 | 2 | if unique.len() != committee.len() { |
165 | 1 | anyhow::bail!("Availability committee contains duplicate node IDs"); |
166 | 1 | } |
167 | 1 | Ok(()) |
168 | 5 | } |
169 | | |
170 | | /// Complete bartender's first availability epoch. |
171 | 0 | pub async fn activate<S: Signer + Clone + Send + Sync + 'static>( |
172 | 0 | client: &Client, signer: &S, bartender: Address, target: Address, committee: &[NodeID], threshold: u16, |
173 | 0 | ) -> anyhow::Result<AvailabilityStatus> { |
174 | 0 | validate_committee(committee, threshold)?; |
175 | | |
176 | 0 | let call = BartenderAvailability::__lyquor_oracle_initializeCall { |
177 | 0 | topic: AVAILABILITY_TOPIC.to_owned(), |
178 | 0 | targetAddr: target, |
179 | | isEvm: false, |
180 | 0 | committee: committee.iter().map(|id| <[u8; 32]>::from(*id).into()).collect(), |
181 | 0 | threshold, |
182 | | }; |
183 | 0 | let submitter = EthSubmitter::new(client.clone(), Arc::new(signer.clone())); |
184 | 0 | let tx_hash = submitter |
185 | 0 | .submit_contract_call(bartender, call.abi_encode().into()) |
186 | 0 | .await |
187 | 0 | .context("Failed to submit availability committee initialization")?; |
188 | 0 | wait_for_transaction(client, tx_hash).await?; |
189 | | |
190 | 0 | poll( |
191 | 0 | format!("Timed out submitting the availability epoch advance; {COMMITTEE_TIMEOUT_HINT}"), |
192 | 0 | || async { |
193 | 0 | let advanced = eth_call( |
194 | 0 | client, |
195 | 0 | bartender, |
196 | 0 | BartenderAvailability::__lyquor_oracle_advance_epochCall { |
197 | 0 | topic: AVAILABILITY_TOPIC.to_owned(), |
198 | 0 | targetAddr: target, |
199 | 0 | isEvm: false, |
200 | 0 | }, |
201 | 0 | ) |
202 | 0 | .await?; |
203 | 0 | Ok(advanced.then_some(())) |
204 | 0 | }, |
205 | | ) |
206 | 0 | .await?; |
207 | 0 | let mut state = poll( |
208 | 0 | format!("Timed out waiting for availability destination epoch 1; {COMMITTEE_TIMEOUT_HINT}"), |
209 | 0 | || async { |
210 | 0 | let state = epoch_state(client, bartender).await?; |
211 | 0 | Ok((state.dest_epoch >= 1).then_some(state)) |
212 | 0 | }, |
213 | | ) |
214 | 0 | .await?; |
215 | | |
216 | 0 | let finalized = eth_call( |
217 | 0 | client, |
218 | 0 | bartender, |
219 | 0 | BartenderAvailability::__lyquor_oracle_finalize_epochCall { |
220 | 0 | topic: AVAILABILITY_TOPIC.to_owned(), |
221 | 0 | targetAddr: target, |
222 | 0 | isEvm: false, |
223 | 0 | }, |
224 | 0 | ) |
225 | 0 | .await?; |
226 | 0 | if !finalized { |
227 | 0 | anyhow::bail!("Bartender did not accept availability epoch finalization"); |
228 | 0 | } |
229 | 0 | let expected = state.dest_epoch; |
230 | 0 | state = poll( |
231 | 0 | format!("Timed out waiting for availability source epoch {expected}; {COMMITTEE_TIMEOUT_HINT}"), |
232 | 0 | || async { |
233 | 0 | let state = epoch_state(client, bartender).await?; |
234 | 0 | Ok((state.source_epoch >= expected).then_some(state)) |
235 | 0 | }, |
236 | | ) |
237 | 0 | .await?; |
238 | | |
239 | 0 | if state.source_epoch == 0 || state.source_epoch != state.dest_epoch { |
240 | 0 | anyhow::bail!( |
241 | | "Availability ceremony did not settle: source epoch {}, destination epoch {}", |
242 | | state.source_epoch, |
243 | | state.dest_epoch |
244 | | ); |
245 | 0 | } |
246 | | |
247 | 0 | let status = status_from_state(client, bartender, state).await?; |
248 | 0 | if status.committee != committee || status.threshold != threshold { |
249 | 0 | anyhow::bail!( |
250 | | "Availability committee is already active with a different configuration; active committee {:?}, threshold {}", |
251 | | status.committee, |
252 | | status.threshold |
253 | | ); |
254 | 0 | } |
255 | 0 | Ok(status) |
256 | 0 | } |
257 | | |
258 | | /// Read bartender's availability committee and deployment-admission status. |
259 | 0 | pub async fn status(client: &Client, bartender: Address) -> anyhow::Result<AvailabilityStatus> { |
260 | 0 | let state = epoch_state(client, bartender).await?; |
261 | 0 | status_from_state(client, bartender, state).await |
262 | 0 | } |
263 | | |
264 | 0 | async fn status_from_state( |
265 | 0 | client: &Client, bartender: Address, state: EpochState, |
266 | 0 | ) -> anyhow::Result<AvailabilityStatus> { |
267 | 0 | let counts = eth_call(client, bartender, BartenderAvailability::get_availability_countsCall {}).await?; |
268 | 0 | let committee = state |
269 | 0 | .config |
270 | 0 | .committee |
271 | 0 | .into_iter() |
272 | 0 | .map(|signer| { |
273 | 0 | NodeID::try_from(signer.key.as_ref()) |
274 | 0 | .map_err(|err| anyhow::anyhow!("Invalid node ID in availability committee: {err:?}")) |
275 | 0 | }) |
276 | 0 | .collect::<anyhow::Result<Vec<_>>>()?; |
277 | 0 | Ok(AvailabilityStatus { |
278 | 0 | source_epoch: state.source_epoch, |
279 | 0 | dest_epoch: state.dest_epoch, |
280 | 0 | committee, |
281 | 0 | threshold: state.config.threshold, |
282 | 0 | admitted_image_count: counts.admittedImages, |
283 | 0 | pending_deployment_count: counts.pendingDeployments, |
284 | 0 | }) |
285 | 0 | } |
286 | | |
287 | | #[cfg(test)] |
288 | | mod tests { |
289 | | use super::*; |
290 | | use lyquor_test::test; |
291 | | |
292 | | #[test] |
293 | | fn activation_rejects_invalid_committee_thresholds_and_duplicates() { |
294 | | let node = NodeID::from(1); |
295 | | assert!(validate_committee(&[], 1).is_err()); |
296 | | assert!(validate_committee(&[node], 0).is_err()); |
297 | | assert!(validate_committee(&[node], 2).is_err()); |
298 | | assert!(validate_committee(&[node, node], 1).is_err()); |
299 | | assert!(validate_committee(&[node], 1).is_ok()); |
300 | | } |
301 | | } |