Skip to main content

ethexe_blob_loader/
lib.rs

1// Copyright (C) Gear Technologies Inc.
2// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
3
4//! # Ethexe Blob Loader
5//!
6//! Fetches Gear program code blobs posted to Ethereum as EIP-4844 blob transactions,
7//! decoding them from the beacon chain into raw WASM bytes. Emits unverified bytes only;
8//! code validation and instrumentation happen later in `ethexe-processor`.
9//!
10//! ## Role in the Stack
11//!
12//! `ethexe-service` constructs a [`BlobLoader`] at startup, stores it as
13//! `Box<dyn BlobLoaderService>`, and drives it inside the main event loop, calling
14//! [`BlobLoaderService::load_codes`] whenever the compute service emits `RequestLoadCodes`.
15//! The loader reads code locations from the local database (via the [`Database`] bound)
16//! and fetches blob data from the execution-layer JSON-RPC and beacon node.
17//!
18//! ## Public API
19//!
20//! - [`BlobLoaderService`] — Trait stored by `ethexe-service`; a fused stream of fetched code blobs driven via `load_codes`
21//! - [`BlobLoader`] — Concrete implementation; constructed with `BlobLoader::new(db, cfg).await`
22//! - [`ConsensusLayerConfig`] — RPC endpoints (`ethereum_rpc`, `ethereum_beacon_rpc`), `beacon_block_time`, retry `attempts`
23//! - [`BlobLoaderEvent`] — Output event; the only variant is `BlobLoaded(CodeAndIdUnchecked)`
24//! - [`BlobLoaderError`] — Public error: `Transport` or `CodeBlobInfoNotFound(CodeId)`
25//! - [`Database`] — Blanket-implemented marker bound: `CodesStorageRO + OnChainStorageRO + Unpin + Send + Clone + 'static`
26//!
27//! ## Invariants
28//!
29//! - Calling [`BlobLoaderService::load_codes`] with an already-pending [`CodeId`] is a no-op.
30//! - The stream never terminates; a failed fetch is logged and silently dropped (no error item).
31
32use alloy::{
33    consensus::{SidecarCoder, SimpleCoder, Transaction},
34    primitives::B256,
35    providers::{Provider, ProviderBuilder, RootProvider},
36    rpc::types::beacon::{genesis::GenesisResponse, sidecar::GetBlobsResponse},
37    transports::{RpcError, TransportErrorKind},
38};
39use ethexe_common::{
40    CodeAndIdUnchecked, CodeBlobInfo,
41    db::{CodesStorageRO, OnChainStorageRO},
42    gear_core::ids::prelude::CodeIdExt,
43};
44use futures::{
45    FutureExt, Stream, StreamExt,
46    future::BoxFuture,
47    stream::{FusedStream, FuturesUnordered},
48};
49use gprimitives::{CodeId, H256};
50use reqwest::Client;
51use std::{collections::HashSet, fmt, num::NonZero, pin::Pin, task::Poll};
52use tokio::{sync::OnceCell, time::Duration};
53
54#[derive(Clone, PartialEq, Eq)]
55pub enum BlobLoaderEvent {
56    BlobLoaded(CodeAndIdUnchecked),
57}
58
59#[derive(thiserror::Error, Debug)]
60pub enum BlobLoaderError {
61    #[error("transport error: {0}")]
62    Transport(#[from] RpcError<TransportErrorKind>),
63    #[error("failed to get code blob info for: {0}")]
64    CodeBlobInfoNotFound(CodeId),
65}
66
67#[derive(thiserror::Error, Debug)]
68enum ReadBlobBundleError {
69    #[error("failed to read blob bundle from beacon node: {0}")]
70    Reqwest(#[from] reqwest::Error),
71    #[error("failed to decode blob bundle response: {0}")]
72    Serde(#[from] serde_json::Error),
73}
74
75#[derive(thiserror::Error, Debug)]
76enum ReaderError {
77    #[error("transport error: {0}")]
78    Transport(#[from] RpcError<TransportErrorKind>),
79    #[error("failed to find transaction by hash: {0}")]
80    TransactionNotFound(H256),
81    #[error("failed to get blob versioned hashes from transaction: {0}")]
82    BlobVersionedHashesNotFound(H256),
83    #[error("failed to get transaction block hash: {0}")]
84    TransactionBlockNotFound(H256),
85    #[error("failed to get block by hash: {0}")]
86    BlockNotFound(H256),
87    #[error("failed to read blob bundle: {0}")]
88    ReadBlob(#[from] ReadBlobBundleError),
89    #[error("failed to decode blobs")]
90    DecodeBlobs,
91    #[error("failed to access genesis time: {0}")]
92    GenesisTimeAccess(reqwest::Error),
93    #[error("received empty blob")]
94    EmptyBlob,
95    #[error("blob code id mismatch: expected {expected:?}, found {found:?}")]
96    CodeIdMismatch { expected: CodeId, found: CodeId },
97}
98
99type LoaderResult<T> = Result<T, BlobLoaderError>;
100type ReaderResult<T> = Result<T, ReaderError>;
101
102pub trait BlobLoaderService:
103    Stream<Item = LoaderResult<BlobLoaderEvent>> + FusedStream + Send + Unpin
104{
105    fn load_codes(&mut self, codes: HashSet<CodeId>) -> LoaderResult<()>;
106
107    fn into_box(self) -> Box<dyn BlobLoaderService>;
108
109    fn pending_codes_len(&self) -> usize;
110}
111
112impl fmt::Debug for BlobLoaderEvent {
113    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
114        match self {
115            BlobLoaderEvent::BlobLoaded(data) => data.fmt(f),
116        }
117    }
118}
119
120#[derive(Clone)]
121pub struct ConsensusLayerConfig {
122    pub ethereum_rpc: String,
123    pub ethereum_beacon_rpc: String,
124    pub beacon_block_time: Duration,
125    pub attempts: NonZero<u8>,
126}
127
128#[derive(Clone)]
129struct ConsensusLayerBlobReader {
130    provider: RootProvider,
131    http_client: Client,
132    config: ConsensusLayerConfig,
133}
134
135impl ConsensusLayerBlobReader {
136    async fn read_blob(&self, expected_code_id: CodeId, tx_hash: H256) -> ReaderResult<Vec<u8>> {
137        let mut last_err = None;
138        let mut previously_received_code_id = None;
139        for attempt in 0..self.config.attempts.get() {
140            log::trace!("trying to get blob, attempt #{attempt}");
141            match self.try_query_blob(tx_hash).await {
142                Ok(blob) => {
143                    match handle_blob(
144                        blob,
145                        expected_code_id,
146                        &mut previously_received_code_id,
147                        attempt,
148                    ) {
149                        Ok(blob) => return Ok(blob),
150                        Err(err) => last_err = Some(err),
151                    }
152                }
153                Err(err) => {
154                    log::warn!("failed to get blob on attempt #{attempt}: {err}");
155                    last_err = Some(err);
156                }
157            }
158
159            tokio::time::sleep(self.config.beacon_block_time).await;
160        }
161
162        Err(last_err.expect("Must be set, because attempts is not zero"))
163    }
164
165    async fn try_query_blob(&self, tx_hash: H256) -> ReaderResult<Vec<u8>> {
166        use ReaderError::*;
167
168        let tx = self
169            .provider
170            .get_transaction_by_hash(tx_hash.0.into())
171            .await?
172            .ok_or(TransactionNotFound(tx_hash))?;
173
174        // TODO: #5102 here may be a problem with code if it has same versioned hashes,
175        // consider to change it to more reliable way.
176        let blob_versioned_hashes = tx
177            .blob_versioned_hashes()
178            .ok_or(BlobVersionedHashesNotFound(tx_hash))?;
179
180        let block_hash = tx.block_hash.ok_or(TransactionBlockNotFound(tx_hash))?;
181
182        let block = self
183            .provider
184            .get_block_by_hash(block_hash)
185            .await?
186            .ok_or(BlockNotFound(H256(block_hash.0)))?;
187
188        // detect anvil by chain id
189        let slot = if let Some(chain_id) = tx.chain_id()
190            && chain_id == 31337
191        {
192            block.header.number
193        } else {
194            static BEACON_GENESIS_BLOCK_TIME: OnceCell<u64> = OnceCell::const_new();
195
196            let beacon_genesis_block_time = *BEACON_GENESIS_BLOCK_TIME
197                .get_or_try_init(|| self.read_genesis_time())
198                .await
199                .map_err(GenesisTimeAccess)?;
200            (block.header.timestamp - beacon_genesis_block_time)
201                / self.config.beacon_block_time.as_secs()
202        };
203
204        let blob_bundle = self
205            .read_blob_bundle(slot, blob_versioned_hashes)
206            .await
207            .map_err(ReadBlob)?;
208
209        let mut coder = SimpleCoder::default();
210        let data = coder
211            .decode_all(&blob_bundle.data)
212            .ok_or(DecodeBlobs)?
213            .concat();
214
215        Ok(data)
216    }
217
218    async fn read_genesis_time(&self) -> reqwest::Result<u64> {
219        let ethereum_beacon_rpc = &self.config.ethereum_beacon_rpc;
220        let response = self
221            .http_client
222            .get(format!("{ethereum_beacon_rpc}/eth/v1/beacon/genesis"))
223            .send()
224            .await?
225            .json::<GenesisResponse>()
226            .await?;
227
228        Ok(response.data.genesis_time)
229    }
230
231    async fn read_blob_bundle(
232        &self,
233        slot: u64,
234        versioned_hashes: &[B256],
235    ) -> Result<GetBlobsResponse, ReadBlobBundleError> {
236        let ethereum_beacon_rpc = &self.config.ethereum_beacon_rpc;
237        let bytes = self
238            .http_client
239            .get(format!(
240                "{ethereum_beacon_rpc}/eth/v1/beacon/blobs/{slot}?versioned_hashes={}",
241                versioned_hashes
242                    .iter()
243                    .map(|versioned_hash| versioned_hash.to_string())
244                    .collect::<Vec<_>>()
245                    .join(",")
246            ))
247            .send()
248            .await?
249            .bytes()
250            .await?;
251        let blobs_response =
252            serde_json::from_slice::<GetBlobsResponse>(&bytes).inspect_err(|err| {
253                log::trace!("failed to decode blob bundle response: {err}, bytes: {bytes:?}")
254            })?;
255        Ok(blobs_response)
256    }
257}
258
259pub trait Database: CodesStorageRO + OnChainStorageRO + Unpin + Send + Clone + 'static {}
260impl<T: CodesStorageRO + OnChainStorageRO + Unpin + Send + Clone + 'static> Database for T {}
261
262pub struct BlobLoader<DB: Database> {
263    futures: FuturesUnordered<BoxFuture<'static, ReaderResult<CodeAndIdUnchecked>>>,
264    codes_loading: HashSet<CodeId>,
265
266    blobs_reader: ConsensusLayerBlobReader,
267    db: DB,
268}
269
270impl<DB: Database> Stream for BlobLoader<DB> {
271    type Item = LoaderResult<BlobLoaderEvent>;
272
273    fn poll_next(
274        mut self: Pin<&mut Self>,
275        cx: &mut std::task::Context<'_>,
276    ) -> std::task::Poll<Option<Self::Item>> {
277        match futures::ready!(self.futures.poll_next_unpin(cx)) {
278            None => Poll::Pending,
279            Some(Err(err)) => {
280                // TODO: #4995 currently in case of error in blob loading we just skip it
281                log::error!("Failed to load blob: {err}, skipping");
282                Poll::Pending
283            }
284            Some(Ok(code_and_id)) => {
285                self.codes_loading.remove(&code_and_id.code_id);
286                Poll::Ready(Some(Ok(BlobLoaderEvent::BlobLoaded(code_and_id))))
287            }
288        }
289    }
290}
291
292impl<DB: Database> FusedStream for BlobLoader<DB> {
293    fn is_terminated(&self) -> bool {
294        false
295    }
296}
297
298impl<DB: Database> BlobLoader<DB> {
299    pub async fn new(db: DB, consensus_cfg: ConsensusLayerConfig) -> LoaderResult<Self> {
300        Ok(Self {
301            futures: FuturesUnordered::new(),
302            codes_loading: HashSet::new(),
303
304            blobs_reader: ConsensusLayerBlobReader {
305                provider: ProviderBuilder::default()
306                    .connect(&consensus_cfg.ethereum_rpc)
307                    .await?,
308                http_client: Client::new(),
309                config: consensus_cfg,
310            },
311            db,
312        })
313    }
314
315    #[cfg(test)]
316    fn new_with_consensus_reader(db: DB, blobs_reader: ConsensusLayerBlobReader) -> Self {
317        Self {
318            futures: FuturesUnordered::new(),
319            codes_loading: HashSet::new(),
320            blobs_reader,
321            db,
322        }
323    }
324}
325
326impl<DB: Database> BlobLoaderService for BlobLoader<DB> {
327    fn into_box(self) -> Box<dyn BlobLoaderService> {
328        Box::new(self)
329    }
330
331    fn pending_codes_len(&self) -> usize {
332        self.codes_loading.len()
333    }
334
335    fn load_codes(&mut self, codes: HashSet<CodeId>) -> LoaderResult<()> {
336        for code_id in codes {
337            if self.codes_loading.contains(&code_id) {
338                continue;
339            }
340
341            let CodeBlobInfo { tx_hash, .. } = self
342                .db
343                .code_blob_info(code_id)
344                .ok_or(BlobLoaderError::CodeBlobInfoNotFound(code_id))?;
345
346            self.codes_loading.insert(code_id);
347
348            if let Some(code) = self.db.original_code(code_id) {
349                log::warn!("Code {code_id} is already loaded, skipping loading from remote source");
350                self.futures
351                    .push(futures::future::ready(Ok(CodeAndIdUnchecked { code_id, code })).boxed());
352            } else {
353                let blobs_reader = self.blobs_reader.clone();
354                self.futures.push(
355                    async move {
356                        blobs_reader
357                            .read_blob(code_id, tx_hash)
358                            .await
359                            .map(|code| CodeAndIdUnchecked { code_id, code })
360                    }
361                    .boxed(),
362                );
363            }
364        }
365
366        Ok(())
367    }
368}
369
370// TODO: #4995 temporary solution to protect against inconsistent blob data,
371// we have second check of code id in ethexe-processor in handle_new_code as well,
372// so this solution must be reconsidered.
373fn handle_blob(
374    blob: Vec<u8>,
375    code_id: CodeId,
376    previously_received_code_id: &mut Option<CodeId>,
377    attempt: u8,
378) -> ReaderResult<Vec<u8>> {
379    if blob.is_empty() {
380        log::warn!("received empty blob on attempt #{attempt}");
381        return Err(ReaderError::EmptyBlob);
382    }
383
384    let received_code_id = CodeId::generate(&blob);
385    if *previously_received_code_id == Some(received_code_id) {
386        log::warn!(
387            "received same blob with invalid id on attempt #{attempt}, code id: {received_code_id:?}, consider blob as loaded then",
388        );
389        return Ok(blob);
390    }
391
392    if code_id != received_code_id {
393        *previously_received_code_id = Some(received_code_id);
394        log::warn!(
395            "received blob code id mismatch on attempt #{attempt}: expected {code_id:?}, got {received_code_id:?}",
396        );
397        return Err(ReaderError::CodeIdMismatch {
398            expected: code_id,
399            found: received_code_id,
400        });
401    }
402
403    Ok(blob)
404}
405
406#[cfg(test)]
407mod tests;