Skip to main content

tiders_ingest/
lib.rs

1#![expect(
2    clippy::should_implement_trait,
3    reason = "LogKind::from_str is a constructor pattern, not Trait impl"
4)]
5#![expect(
6    clippy::field_reassign_with_default,
7    reason = "ProviderConfig is built by setting fields after construction"
8)]
9
10//! # tiders-ingest
11//!
12//! Streams blockchain data from multiple provider backends as Apache Arrow RecordBatches.
13//!
14//! Supports both EVM (Ethereum) and SVM (Solana) chains through a unified [`Query`] enum.
15//! Data is fetched from one of three provider backends:
16//!
17//! - **SQD** ([`ProviderKind::Sqd`]) — SQD Network portal for historical data.
18//! - **HyperSync** ([`ProviderKind::Hypersync`]) — Envio HyperSync for fast historical data.
19//! - **RPC** ([`ProviderKind::Rpc`]) — Direct JSON-RPC node connection.
20//!
21//! Use [`start_stream`] to create an async stream of `BTreeMap<String, RecordBatch>` where
22//! keys are table names (e.g. "blocks", "transactions", "logs").
23
24use std::{collections::BTreeMap, pin::Pin, sync::Arc};
25
26#[cfg(feature = "pyo3")]
27use anyhow::anyhow;
28use anyhow::{Context, Result};
29use arrow::array::UInt64Array;
30use arrow::compute::kernels::aggregate::max as array_max;
31use arrow::record_batch::RecordBatch;
32use futures_lite::{Stream, StreamExt};
33use provider::common::{evm_query_to_generic, svm_query_to_generic};
34use serde::de::DeserializeOwned;
35
36pub mod evm;
37mod provider;
38mod rayon_async;
39pub mod svm;
40
41/// Top-level query type: either an EVM or SVM blockchain data query.
42#[derive(Debug, Clone)]
43pub enum Query {
44    Evm(evm::Query),
45    Svm(svm::Query),
46}
47
48#[cfg(feature = "pyo3")]
49impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Query {
50    type Error = pyo3::PyErr;
51    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
52        use pyo3::types::PyAnyMethods;
53
54        let kind = ob.getattr("kind").context("get kind attribute")?;
55        let kind: &str = kind.extract().context("kind as str")?;
56
57        let query = ob.getattr("params").context("get params attribute")?;
58
59        match kind {
60            "evm" => Ok(Self::Evm(query.extract().context("parse query")?)),
61            "svm" => Ok(Self::Svm(query.extract().context("parse query")?)),
62            _ => Err(anyhow!("unknown query kind: {kind}").into()),
63        }
64    }
65}
66
67/// Configuration for a data provider, including connection details and retry settings.
68#[derive(Debug, Clone)]
69#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
70pub struct ProviderConfig {
71    pub kind: ProviderKind,
72    pub url: Option<String>,
73    pub bearer_token: Option<String>,
74    pub max_num_retries: Option<usize>,
75    pub retry_backoff_ms: Option<u64>,
76    pub retry_base_ms: Option<u64>,
77    pub retry_ceiling_ms: Option<u64>,
78    pub req_timeout_millis: Option<u64>,
79    pub stop_on_head: bool,
80    pub head_poll_interval_millis: Option<u64>,
81    pub buffer_size: Option<usize>,
82    // RPC-specific fields
83    pub compute_units_per_second: Option<u64>,
84    pub batch_size: Option<usize>,
85    pub reorg_safe_distance: Option<u64>,
86    pub trace_method: Option<RpcTraceMethod>,
87}
88
89impl ProviderConfig {
90    pub fn new(kind: ProviderKind) -> Self {
91        Self {
92            kind,
93            url: None,
94            bearer_token: None,
95            max_num_retries: None,
96            retry_backoff_ms: None,
97            retry_base_ms: None,
98            retry_ceiling_ms: None,
99            req_timeout_millis: None,
100            stop_on_head: false,
101            head_poll_interval_millis: None,
102            buffer_size: None,
103            compute_units_per_second: None,
104            batch_size: None,
105            reorg_safe_distance: None,
106            trace_method: None,
107        }
108    }
109}
110
111/// Selects which RPC method to use for fetching EVM execution traces.
112#[derive(Debug, Clone, Copy)]
113pub enum RpcTraceMethod {
114    /// Uses `trace_block` (Parity/OpenEthereum style).
115    TraceBlock,
116    /// Uses `debug_traceBlockByNumber` (Geth style).
117    DebugTraceBlockByNumber,
118}
119
120#[cfg(feature = "pyo3")]
121impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for RpcTraceMethod {
122    type Error = pyo3::PyErr;
123    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
124        let out: &str = ob.extract().context("read as string")?;
125
126        match out {
127            "trace_block" => Ok(Self::TraceBlock),
128            "debug_trace_block_by_number" => Ok(Self::DebugTraceBlockByNumber),
129            _ => Err(anyhow!("unknown trace method: {out}").into()),
130        }
131    }
132}
133
134/// The type of data provider backend.
135#[derive(Debug, Clone, Copy)]
136pub enum ProviderKind {
137    /// SQD Network portal.
138    Sqd,
139    /// Envio HyperSync.
140    Hypersync,
141    /// Direct JSON-RPC node.
142    Rpc,
143}
144
145#[cfg(feature = "pyo3")]
146impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ProviderKind {
147    type Error = pyo3::PyErr;
148    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
149        let out: &str = ob.extract().context("read as string")?;
150
151        match out {
152            "sqd" => Ok(Self::Sqd),
153            "hypersync" => Ok(Self::Hypersync),
154            "rpc" => Ok(Self::Rpc),
155            _ => Err(anyhow!("unknown provider kind: {out}").into()),
156        }
157    }
158}
159
160/// One yielded chunk from [`start_stream`]: the projected tables plus the
161/// highest block-id observed in this chunk (`None` if the chunk has no rows
162/// in any table carrying a block-id column).
163#[derive(Debug)]
164pub struct StreamItem {
165    pub data: BTreeMap<String, RecordBatch>,
166    pub last_block: Option<u64>,
167}
168
169/// Public stream type returned by [`start_stream`].
170pub type DataStream = Pin<Box<dyn Stream<Item = Result<StreamItem>> + Send + Sync>>;
171
172/// Internal stream type returned by each provider before projection /
173/// `last_block` extraction.
174pub(crate) type ProviderStream =
175    Pin<Box<dyn Stream<Item = Result<BTreeMap<String, RecordBatch>>> + Send + Sync>>;
176
177/// Returns the maximum value across every column in `data` whose name is one
178/// of the known block-id column names (`block_number` for EVM non-block
179/// tables, `number` for the EVM blocks table, `slot` for SVM tables).
180///
181/// Runs against pre-projection batches, where these columns are guaranteed to
182/// be present (they are required by the local query filter step).
183fn extract_last_block(data: &BTreeMap<String, RecordBatch>) -> Option<u64> {
184    const BLOCK_ID_COLUMNS: &[&str] = &["block_number", "number", "slot"];
185
186    let mut out: Option<u64> = None;
187    for batch in data.values() {
188        for col_name in BLOCK_ID_COLUMNS {
189            let Some(col) = batch.column_by_name(col_name) else {
190                continue;
191            };
192            let Some(arr) = col.as_any().downcast_ref::<UInt64Array>() else {
193                continue;
194            };
195            if let Some(m) = array_max(arr) {
196                out = Some(out.map_or(m, |cur| cur.max(m)));
197            }
198        }
199    }
200    out
201}
202
203fn make_req_fields<T: DeserializeOwned>(query: &tiders_query::Query) -> Result<T> {
204    let mut req_fields_query = query.clone();
205    req_fields_query
206        .add_request_and_include_fields()
207        .context("add req and include fields")?;
208
209    let fields = req_fields_query
210        .fields
211        .into_iter()
212        .map(|(k, v)| -> Result<_> {
213            Ok((
214                k.strip_suffix('s')
215                    .context("field key should end with 's'")?
216                    .to_owned(),
217                v.into_iter()
218                    .map(|v| (v, true))
219                    .collect::<BTreeMap<String, bool>>(),
220            ))
221        })
222        .collect::<Result<BTreeMap<String, _>>>()?;
223
224    let json_value = serde_json::to_value(&fields).context("serialize fields to JSON")?;
225    serde_json::from_value(json_value).context("deserialize fields from JSON")
226}
227
228/// Creates an async stream of Arrow RecordBatches from the configured provider.
229pub async fn start_stream(provider_config: ProviderConfig, mut query: Query) -> Result<DataStream> {
230    let generic_query = match &mut query {
231        Query::Evm(evm_query) => {
232            let generic_query = evm_query_to_generic(evm_query).context("validate evm query")?;
233
234            evm_query.fields = make_req_fields(&generic_query).context("make req fields")?;
235
236            generic_query
237        }
238        Query::Svm(svm_query) => {
239            let generic_query = svm_query_to_generic(svm_query);
240
241            svm_query.fields = make_req_fields(&generic_query).context("make req fields")?;
242
243            generic_query
244        }
245    };
246    let generic_query = Arc::new(generic_query);
247
248    let stream = match provider_config.kind {
249        ProviderKind::Sqd => {
250            provider::sqd::start_stream(provider_config, query).context("start sqd stream")?
251        }
252        ProviderKind::Hypersync => provider::hypersync::start_stream(provider_config, query)
253            .await
254            .context("start hypersync stream")?,
255        ProviderKind::Rpc => {
256            provider::rpc::start_stream(&provider_config, query).context("start rpc stream")?
257        }
258    };
259
260    let stream = stream.then(move |res| {
261        let generic_query = Arc::clone(&generic_query);
262        async {
263            rayon_async::spawn(move || {
264                res.and_then(move |data| {
265                    let last_block = extract_last_block(&data);
266                    let data = tiders_query::run_query(&data, &generic_query)
267                        .context("run local query")?;
268                    Ok(StreamItem { data, last_block })
269                })
270            })
271            .await
272            .context("rayon task was cancelled")
273            .and_then(|r| r)
274        }
275    });
276
277    Ok(Box::pin(stream))
278}
279
280#[cfg(test)]
281mod tests {
282
283    use super::*;
284    use crate::svm::*;
285    use parquet::arrow::ArrowWriter;
286    use std::fs::File;
287
288    #[tokio::test]
289    #[ignore]
290    async fn simple_svm_start_stream() {
291        let mut provider_config = ProviderConfig::new(ProviderKind::Sqd);
292        provider_config.url = Some("https://portal.sqd.dev/datasets/solana-mainnet".to_string());
293
294        let program_id = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
295        let program_id: [u8; 32] = bs58::decode(program_id)
296            .into_vec()
297            .unwrap()
298            .try_into()
299            .unwrap();
300        let program_id = Address(program_id);
301
302        let query = crate::Query::Svm(svm::Query {
303            from_block: 329443000,
304            to_block: Some(329443000),
305            include_all_blocks: false,
306            fields: Fields {
307                instruction: InstructionFields::all(),
308                transaction: TransactionFields::default(),
309                log: LogFields::default(),
310                balance: BalanceFields::default(),
311                token_balance: TokenBalanceFields::default(),
312                reward: RewardFields::default(),
313                block: BlockFields::default(),
314            },
315            instructions: vec![
316                // InstructionRequest::default() ,
317                InstructionRequest {
318                    program_id: vec![program_id],
319                    discriminator: vec![Data(vec![12, 96, 49, 128, 22])],
320                    ..Default::default()
321                },
322            ],
323            transactions: vec![],
324            logs: vec![],
325            balances: vec![],
326            token_balances: vec![],
327            rewards: vec![],
328        });
329        let mut stream = start_stream(provider_config, query).await.unwrap();
330        let item = stream.next().await.unwrap().unwrap();
331        for (k, v) in item.data.into_iter() {
332            let mut file = File::create(format!("{}.parquet", k)).unwrap();
333            let mut writer = ArrowWriter::try_new(&mut file, v.schema(), None).unwrap();
334            writer.write(&v).unwrap();
335            writer.close().unwrap();
336        }
337    }
338
339    /// End-to-end smoke test for the public `start_stream` surface against the
340    /// SQD ethereum-mainnet portal: fetches ERC-20 Transfer logs over a small
341    /// block range and asserts the `StreamItem` invariants
342    /// (`from_block` / `to_block` are mirrored on the query; `last_block` is
343    /// monotonic, within the requested range, and reaches `to_block` once the
344    /// stream exhausts).
345    #[tokio::test(flavor = "multi_thread")]
346    #[ignore]
347    async fn simple_start_stream() {
348        use crate::evm::{Fields, LogFields, LogRequest, Query as EvmQuery, Topic};
349
350        // keccak256("Transfer(address,address,uint256)")
351        let transfer_topic0: [u8; 32] = {
352            let mut out = [0u8; 32];
353            faster_hex::hex_decode(
354                b"ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
355                &mut out,
356            )
357            .unwrap();
358            out
359        };
360
361        let from_block = 18_000_000u64;
362        let to_block = 18_000_010u64;
363        // let to_block = 18_001_000u64;
364
365        let mut provider_config = ProviderConfig::new(ProviderKind::Sqd);
366        provider_config.url = Some("https://portal.sqd.dev/datasets/ethereum-mainnet".to_string());
367        provider_config.stop_on_head = true;
368
369        // let mut provider_config = ProviderConfig::new(ProviderKind::Hypersync);
370        // provider_config.url =
371        //     Some("https://eth.hypersync.xyz/".to_string());
372        // provider_config.stop_on_head = true;
373        // provider_config.bearer_token = Some("add_token".to_string());
374
375        let mut fields = Fields::default();
376        fields.log = LogFields {
377            log_index: true,
378            block_number: true,
379            address: true,
380            topic0: true,
381            ..LogFields::default()
382        };
383
384        let query = crate::Query::Evm(EvmQuery {
385            from_block,
386            to_block: Some(to_block),
387            include_all_blocks: false,
388            logs: vec![LogRequest {
389                topic0: vec![Topic(transfer_topic0)],
390                ..LogRequest::default()
391            }],
392            transactions: vec![],
393            traces: vec![],
394            fields,
395        });
396
397        let mut stream = start_stream(provider_config, query).await.unwrap();
398
399        println!("from_block={} to_block={}", from_block, to_block);
400
401        let mut highest: Option<u64> = None;
402        let mut total_rows: usize = 0;
403
404        while let Some(res) = stream.next().await {
405            let item = res.unwrap();
406            println!("item last_block={:?}", item.last_block);
407
408            // Every yielded last_block must sit inside the requested window.
409            if let Some(lb) = item.last_block {
410                assert!(
411                    lb >= from_block,
412                    "last_block {} < from_block {}",
413                    lb,
414                    from_block
415                );
416                assert!(lb <= to_block, "last_block {} > to_block {}", lb, to_block);
417            }
418
419            // Monotonicity across items.
420            if let (Some(prev), Some(cur)) = (highest, item.last_block) {
421                assert!(
422                    cur >= prev,
423                    "last_block went backwards: {} -> {}",
424                    prev,
425                    cur
426                );
427            }
428            if item.last_block.is_some() {
429                highest = item.last_block;
430            }
431
432            if let Some(logs) = item.data.get("logs") {
433                total_rows += logs.num_rows();
434            }
435        }
436
437        println!(
438            "final: from_block={} to_block={} last_block={:?} total_rows={}",
439            from_block, to_block, highest, total_rows
440        );
441
442        // The stream must have delivered at least one Transfer log in a
443        // 10-block window of mainnet, and must have reached the configured
444        // upper bound by the time it closed.
445        assert!(
446            total_rows > 0,
447            "expected at least one Transfer log in window"
448        );
449        assert_eq!(highest, Some(to_block));
450    }
451}