tiders_ingest/
svm.rs

1//! SVM (Solana) query types and field selection for the ingest layer.
2
3#[cfg(feature = "pyo3")]
4use anyhow::Context;
5use anyhow::{anyhow, Result};
6use serde::{Deserialize, Serialize};
7
8/// SVM (Solana) blockchain data query specifying slot range, filters, and field selections.
9#[derive(Default, Debug, Clone)]
10#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
11pub struct Query {
12    pub from_block: u64,
13    pub to_block: Option<u64>,
14    pub include_all_blocks: bool,
15    pub fields: Fields,
16    pub instructions: Vec<InstructionRequest>,
17    pub transactions: Vec<TransactionRequest>,
18    pub logs: Vec<LogRequest>,
19    pub balances: Vec<BalanceRequest>,
20    pub token_balances: Vec<TokenBalanceRequest>,
21    pub rewards: Vec<RewardRequest>,
22}
23
24/// A 32-byte Solana public key.
25#[derive(Debug, Clone, Copy)]
26pub struct Address(pub [u8; 32]);
27
28/// Variable-length binary data (instruction data or discriminator).
29#[derive(Debug, Clone)]
30pub struct Data(pub Vec<u8>);
31
32/// Fixed-size data prefix for instruction filtering (1 byte).
33#[derive(Debug, Clone, Copy)]
34pub struct D1(pub [u8; 1]);
35
36/// Fixed-size data prefix for instruction filtering (2 bytes).
37#[derive(Debug, Clone, Copy)]
38pub struct D2(pub [u8; 2]);
39
40/// Fixed-size data prefix for instruction filtering (3 bytes).
41#[derive(Debug, Clone, Copy)]
42pub struct D3(pub [u8; 3]);
43
44/// Fixed-size data prefix for instruction filtering (4 bytes).
45#[derive(Debug, Clone, Copy)]
46pub struct D4(pub [u8; 4]);
47
48/// Fixed-size data prefix for instruction filtering (8 bytes).
49#[derive(Debug, Clone, Copy)]
50pub struct D8(pub [u8; 8]);
51
52#[cfg(feature = "pyo3")]
53fn extract_base58<const N: usize>(ob: &pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult<[u8; N]> {
54    use pyo3::types::PyAnyMethods;
55
56    let s: &str = ob.extract()?;
57    let mut out = [0; N];
58
59    bs58::decode(s)
60        .with_alphabet(bs58::Alphabet::BITCOIN)
61        .onto(&mut out)
62        .context("decode base58")?;
63
64    Ok(out)
65}
66
67#[cfg(feature = "pyo3")]
68fn extract_data<const N: usize>(ob: &pyo3::Bound<'_, pyo3::PyAny>) -> pyo3::PyResult<[u8; N]> {
69    use pyo3::types::PyAnyMethods;
70    use pyo3::types::PyTypeMethods;
71
72    let ob_type: String = ob.get_type().name()?.to_string();
73    match ob_type.as_str() {
74        "str" => {
75            let s: &str = ob.extract()?;
76            let out = hex_to_bytes(s).context("failed to decode hex")?;
77            if out.len() != N {
78                return Err(anyhow!("expected length {}, got {}", N, out.len()).into());
79            }
80            let out: [u8; N] = out
81                .try_into()
82                .map_err(|e| anyhow!("failed to convert to array: {e:?}"))?;
83            Ok(out)
84        }
85        "bytes" => {
86            let out: Vec<u8> = ob.extract()?;
87            if out.len() != N {
88                return Err(anyhow!("expected length {}, got {}", N, out.len()).into());
89            }
90            let out: [u8; N] = out
91                .try_into()
92                .map_err(|e| anyhow!("failed to convert to array: {e:?}"))?;
93            Ok(out)
94        }
95        _ => Err(anyhow!("unknown type: {ob_type}").into()),
96    }
97}
98
99#[cfg(feature = "pyo3")]
100fn hex_to_bytes(hex_string: &str) -> Result<Vec<u8>> {
101    let hex_string = hex_string.strip_prefix("0x").unwrap_or(hex_string);
102    let hex_string = if hex_string.len() % 2 == 1 {
103        format!("0{hex_string}",)
104    } else {
105        hex_string.to_string()
106    };
107    let out = (0..hex_string.len())
108        .step_by(2)
109        .map(|i| {
110            u8::from_str_radix(&hex_string[i..i + 2], 16)
111                .context("failed to parse hexstring to bytes")
112        })
113        .collect::<Result<Vec<_>, _>>()?;
114
115    Ok(out)
116}
117
118#[cfg(feature = "pyo3")]
119impl<'py> pyo3::FromPyObject<'py> for Address {
120    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
121        let out = extract_base58(ob)?;
122        Ok(Self(out))
123    }
124}
125
126#[cfg(feature = "pyo3")]
127impl<'py> pyo3::FromPyObject<'py> for Data {
128    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
129        use pyo3::types::PyAnyMethods;
130        use pyo3::types::PyTypeMethods;
131
132        let ob_type: String = ob.get_type().name()?.to_string();
133        match ob_type.as_str() {
134            "str" => {
135                let s: &str = ob.extract()?;
136                let out = hex_to_bytes(s).context("failed to decode hex")?;
137                Ok(Self(out))
138            }
139            "bytes" => {
140                let out: Vec<u8> = ob.extract()?;
141                Ok(Self(out))
142            }
143            _ => Err(anyhow!("unknown type: {ob_type}").into()),
144        }
145    }
146}
147
148#[cfg(feature = "pyo3")]
149impl<'py> pyo3::FromPyObject<'py> for D1 {
150    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
151        let out = extract_data(ob)?;
152        Ok(Self(out))
153    }
154}
155
156#[cfg(feature = "pyo3")]
157impl<'py> pyo3::FromPyObject<'py> for D2 {
158    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
159        let out = extract_data(ob)?;
160        Ok(Self(out))
161    }
162}
163
164#[cfg(feature = "pyo3")]
165impl<'py> pyo3::FromPyObject<'py> for D3 {
166    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
167        let out = extract_data(ob)?;
168        Ok(Self(out))
169    }
170}
171
172#[cfg(feature = "pyo3")]
173impl<'py> pyo3::FromPyObject<'py> for D4 {
174    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
175        let out = extract_data(ob)?;
176        Ok(Self(out))
177    }
178}
179
180#[cfg(feature = "pyo3")]
181impl<'py> pyo3::FromPyObject<'py> for D8 {
182    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
183        let out = extract_data(ob)?;
184        Ok(Self(out))
185    }
186}
187
188/// Filters for selecting Solana instructions by program ID, discriminator, and accounts.
189#[derive(Default, Debug, Clone)]
190#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
191#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
192pub struct InstructionRequest {
193    pub program_id: Vec<Address>,
194    pub discriminator: Vec<Data>,
195    pub d1: Vec<D1>,
196    pub d2: Vec<D2>,
197    pub d3: Vec<D3>,
198    pub d4: Vec<D4>,
199    pub d8: Vec<D8>,
200    pub a0: Vec<Address>,
201    pub a1: Vec<Address>,
202    pub a2: Vec<Address>,
203    pub a3: Vec<Address>,
204    pub a4: Vec<Address>,
205    pub a5: Vec<Address>,
206    pub a6: Vec<Address>,
207    pub a7: Vec<Address>,
208    pub a8: Vec<Address>,
209    pub a9: Vec<Address>,
210    pub is_committed: bool,
211    pub include_transactions: bool,
212    pub include_transaction_token_balances: bool,
213    pub include_logs: bool,
214    pub include_inner_instructions: bool,
215    pub include_blocks: bool,
216}
217
218/// Filters for selecting Solana transactions by fee payer.
219#[derive(Default, Debug, Clone)]
220#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
221pub struct TransactionRequest {
222    pub fee_payer: Vec<Address>,
223    pub include_instructions: bool,
224    pub include_logs: bool,
225    pub include_blocks: bool,
226}
227
228/// Filters for selecting Solana program logs by program ID and kind.
229#[derive(Default, Debug, Clone)]
230#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
231pub struct LogRequest {
232    pub program_id: Vec<Address>,
233    pub kind: Vec<LogKind>,
234    pub include_transactions: bool,
235    pub include_instructions: bool,
236    pub include_blocks: bool,
237}
238
239/// The type of Solana program log message.
240#[derive(Debug, Clone, Copy)]
241pub enum LogKind {
242    /// Standard program log message (sol_log).
243    Log,
244    /// Base64-encoded program data message (sol_log_data).
245    Data,
246    /// Other log message types.
247    Other,
248}
249
250impl LogKind {
251    pub fn as_str(&self) -> &str {
252        match self {
253            Self::Log => "log",
254            Self::Data => "data",
255            Self::Other => "other",
256        }
257    }
258
259    pub fn from_str(s: &str) -> Result<Self> {
260        match s {
261            "log" => Ok(Self::Log),
262            "data" => Ok(Self::Data),
263            "other" => Ok(Self::Other),
264            _ => Err(anyhow!("unknown log kind: {s}")),
265        }
266    }
267}
268
269#[cfg(feature = "pyo3")]
270impl<'py> pyo3::FromPyObject<'py> for LogKind {
271    fn extract_bound(ob: &pyo3::Bound<'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
272        use pyo3::types::PyAnyMethods;
273
274        let s: &str = ob.extract().context("extract string")?;
275
276        Ok(Self::from_str(s).context("from str")?)
277    }
278}
279
280/// Filters for selecting Solana native SOL balance changes.
281#[derive(Default, Debug, Clone)]
282#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
283pub struct BalanceRequest {
284    pub account: Vec<Address>,
285    pub include_transactions: bool,
286    pub include_transaction_instructions: bool,
287    pub include_blocks: bool,
288}
289
290/// Filters for selecting Solana SPL token balance changes.
291#[derive(Default, Debug, Clone)]
292#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
293pub struct TokenBalanceRequest {
294    pub account: Vec<Address>,
295    pub pre_program_id: Vec<Address>,
296    pub post_program_id: Vec<Address>,
297    pub pre_mint: Vec<Address>,
298    pub post_mint: Vec<Address>,
299    pub pre_owner: Vec<Address>,
300    pub post_owner: Vec<Address>,
301    pub include_transactions: bool,
302    pub include_transaction_instructions: bool,
303    pub include_blocks: bool,
304}
305
306/// Filters for selecting Solana validator rewards.
307#[derive(Default, Debug, Clone)]
308#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
309pub struct RewardRequest {
310    pub pubkey: Vec<Address>,
311    pub include_blocks: bool,
312}
313
314/// Controls which columns are included in the response for each SVM table type.
315#[derive(Deserialize, Serialize, Default, Debug, Clone, Copy)]
316#[serde(default)]
317#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
318pub struct Fields {
319    pub instruction: InstructionFields,
320    pub transaction: TransactionFields,
321    pub log: LogFields,
322    pub balance: BalanceFields,
323    pub token_balance: TokenBalanceFields,
324    pub reward: RewardFields,
325    pub block: BlockFields,
326}
327
328impl Fields {
329    pub fn all() -> Self {
330        Self {
331            instruction: InstructionFields::all(),
332            transaction: TransactionFields::all(),
333            log: LogFields::all(),
334            balance: BalanceFields::all(),
335            token_balance: TokenBalanceFields::all(),
336            reward: RewardFields::all(),
337            block: BlockFields::all(),
338        }
339    }
340}
341
342/// Field selector for Solana instruction data columns.
343#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
344#[serde(default)]
345#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
346#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
347pub struct InstructionFields {
348    pub block_slot: bool,
349    pub block_hash: bool,
350    pub transaction_index: bool,
351    pub instruction_address: bool,
352    pub program_id: bool,
353    pub a0: bool,
354    pub a1: bool,
355    pub a2: bool,
356    pub a3: bool,
357    pub a4: bool,
358    pub a5: bool,
359    pub a6: bool,
360    pub a7: bool,
361    pub a8: bool,
362    pub a9: bool,
363    pub rest_of_accounts: bool,
364    pub data: bool,
365    pub d1: bool,
366    pub d2: bool,
367    pub d4: bool,
368    pub d8: bool,
369    pub error: bool,
370    pub compute_units_consumed: bool,
371    pub is_committed: bool,
372    pub has_dropped_log_messages: bool,
373}
374
375impl InstructionFields {
376    pub fn all() -> Self {
377        InstructionFields {
378            block_slot: true,
379            block_hash: true,
380            transaction_index: true,
381            instruction_address: true,
382            program_id: true,
383            a0: true,
384            a1: true,
385            a2: true,
386            a3: true,
387            a4: true,
388            a5: true,
389            a6: true,
390            a7: true,
391            a8: true,
392            a9: true,
393            rest_of_accounts: true,
394            data: true,
395            d1: true,
396            d2: true,
397            d4: true,
398            d8: true,
399            error: true,
400            compute_units_consumed: true,
401            is_committed: true,
402            has_dropped_log_messages: true,
403        }
404    }
405}
406
407/// Field selector for Solana transaction data columns.
408#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
409#[serde(default)]
410#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
411#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
412pub struct TransactionFields {
413    pub block_slot: bool,
414    pub block_hash: bool,
415    pub transaction_index: bool,
416    pub signature: bool,
417    pub version: bool,
418    pub account_keys: bool,
419    pub address_table_lookups: bool,
420    pub num_readonly_signed_accounts: bool,
421    pub num_readonly_unsigned_accounts: bool,
422    pub num_required_signatures: bool,
423    pub recent_blockhash: bool,
424    pub signatures: bool,
425    pub err: bool,
426    pub fee: bool,
427    pub compute_units_consumed: bool,
428    pub loaded_readonly_addresses: bool,
429    pub loaded_writable_addresses: bool,
430    pub fee_payer: bool,
431    pub has_dropped_log_messages: bool,
432}
433
434impl TransactionFields {
435    pub fn all() -> Self {
436        TransactionFields {
437            block_slot: true,
438            block_hash: true,
439            transaction_index: true,
440            signature: true,
441            version: true,
442            account_keys: true,
443            address_table_lookups: true,
444            num_readonly_signed_accounts: true,
445            num_readonly_unsigned_accounts: true,
446            num_required_signatures: true,
447            recent_blockhash: true,
448            signatures: true,
449            err: true,
450            fee: true,
451            compute_units_consumed: true,
452            loaded_readonly_addresses: true,
453            loaded_writable_addresses: true,
454            fee_payer: true,
455            has_dropped_log_messages: true,
456        }
457    }
458}
459
460/// Field selector for Solana log data columns.
461#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
462#[serde(default)]
463#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
464#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
465pub struct LogFields {
466    pub block_slot: bool,
467    pub block_hash: bool,
468    pub transaction_index: bool,
469    pub log_index: bool,
470    pub instruction_address: bool,
471    pub program_id: bool,
472    pub kind: bool,
473    pub message: bool,
474}
475
476impl LogFields {
477    pub fn all() -> Self {
478        LogFields {
479            block_slot: true,
480            block_hash: true,
481            transaction_index: true,
482            log_index: true,
483            instruction_address: true,
484            program_id: true,
485            kind: true,
486            message: true,
487        }
488    }
489}
490
491/// Field selector for Solana balance data columns.
492#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
493#[serde(default)]
494#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
495#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
496pub struct BalanceFields {
497    pub block_slot: bool,
498    pub block_hash: bool,
499    pub transaction_index: bool,
500    pub account: bool,
501    pub pre: bool,
502    pub post: bool,
503}
504
505impl BalanceFields {
506    pub fn all() -> Self {
507        BalanceFields {
508            block_slot: true,
509            block_hash: true,
510            transaction_index: true,
511            account: true,
512            pre: true,
513            post: true,
514        }
515    }
516}
517
518/// Field selector for Solana token balance data columns.
519#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
520#[serde(default)]
521#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
522#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
523pub struct TokenBalanceFields {
524    pub block_slot: bool,
525    pub block_hash: bool,
526    pub transaction_index: bool,
527    pub account: bool,
528    pub pre_mint: bool,
529    pub post_mint: bool,
530    pub pre_decimals: bool,
531    pub post_decimals: bool,
532    pub pre_program_id: bool,
533    pub post_program_id: bool,
534    pub pre_owner: bool,
535    pub post_owner: bool,
536    pub pre_amount: bool,
537    pub post_amount: bool,
538}
539
540impl TokenBalanceFields {
541    pub fn all() -> Self {
542        TokenBalanceFields {
543            block_slot: true,
544            block_hash: true,
545            transaction_index: true,
546            account: true,
547            pre_mint: true,
548            post_mint: true,
549            pre_decimals: true,
550            post_decimals: true,
551            pre_program_id: true,
552            post_program_id: true,
553            pre_owner: true,
554            post_owner: true,
555            pre_amount: true,
556            post_amount: true,
557        }
558    }
559}
560
561/// Field selector for Solana reward data columns.
562#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
563#[serde(default)]
564#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
565#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
566pub struct RewardFields {
567    pub block_slot: bool,
568    pub block_hash: bool,
569    pub pubkey: bool,
570    pub lamports: bool,
571    pub post_balance: bool,
572    pub reward_type: bool,
573    pub commission: bool,
574}
575
576impl RewardFields {
577    pub fn all() -> Self {
578        RewardFields {
579            block_slot: true,
580            block_hash: true,
581            pubkey: true,
582            lamports: true,
583            post_balance: true,
584            reward_type: true,
585            commission: true,
586        }
587    }
588}
589
590/// Field selector for Solana block data columns.
591#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
592#[serde(default)]
593#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
594#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
595pub struct BlockFields {
596    pub slot: bool,
597    pub hash: bool,
598    pub parent_slot: bool,
599    pub parent_hash: bool,
600    pub height: bool,
601    pub timestamp: bool,
602}
603
604impl BlockFields {
605    pub fn all() -> Self {
606        BlockFields {
607            slot: true,
608            hash: true,
609            parent_slot: true,
610            parent_hash: true,
611            height: true,
612            timestamp: true,
613        }
614    }
615}