Coverage Report

Created: 2026-08-18 16:04

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}