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
10use 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#[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#[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 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#[derive(Debug, Clone, Copy)]
113pub enum RpcTraceMethod {
114 TraceBlock,
116 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#[derive(Debug, Clone, Copy)]
136pub enum ProviderKind {
137 Sqd,
139 Hypersync,
141 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#[derive(Debug)]
164pub struct StreamItem {
165 pub data: BTreeMap<String, RecordBatch>,
166 pub last_block: Option<u64>,
167}
168
169pub type DataStream = Pin<Box<dyn Stream<Item = Result<StreamItem>> + Send + Sync>>;
171
172pub(crate) type ProviderStream =
175 Pin<Box<dyn Stream<Item = Result<BTreeMap<String, RecordBatch>>> + Send + Sync>>;
176
177fn 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
228pub 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 {
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 #[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 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 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 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 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 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 assert!(
446 total_rows > 0,
447 "expected at least one Transfer log in window"
448 );
449 assert_eq!(highest, Some(to_block));
450 }
451}