Skip to main content

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<'a, 'py> pyo3::FromPyObject<'a, 'py> for Address {
120    type Error = pyo3::PyErr;
121    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
122        let out = extract_base58(&ob)?;
123        Ok(Self(out))
124    }
125}
126
127#[cfg(feature = "pyo3")]
128impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for Data {
129    type Error = pyo3::PyErr;
130    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
131        use pyo3::types::PyAnyMethods;
132        use pyo3::types::PyTypeMethods;
133
134        let ob_type: String = ob.get_type().name()?.to_string();
135        match ob_type.as_str() {
136            "str" => {
137                let s: &str = ob.extract()?;
138                let out = hex_to_bytes(s).context("failed to decode hex")?;
139                Ok(Self(out))
140            }
141            "bytes" => {
142                let out: Vec<u8> = ob.extract()?;
143                Ok(Self(out))
144            }
145            _ => Err(anyhow!("unknown type: {ob_type}").into()),
146        }
147    }
148}
149
150#[cfg(feature = "pyo3")]
151impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for D1 {
152    type Error = pyo3::PyErr;
153    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
154        let out = extract_data(&ob)?;
155        Ok(Self(out))
156    }
157}
158
159#[cfg(feature = "pyo3")]
160impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for D2 {
161    type Error = pyo3::PyErr;
162    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
163        let out = extract_data(&ob)?;
164        Ok(Self(out))
165    }
166}
167
168#[cfg(feature = "pyo3")]
169impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for D3 {
170    type Error = pyo3::PyErr;
171    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
172        let out = extract_data(&ob)?;
173        Ok(Self(out))
174    }
175}
176
177#[cfg(feature = "pyo3")]
178impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for D4 {
179    type Error = pyo3::PyErr;
180    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
181        let out = extract_data(&ob)?;
182        Ok(Self(out))
183    }
184}
185
186#[cfg(feature = "pyo3")]
187impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for D8 {
188    type Error = pyo3::PyErr;
189    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
190        let out = extract_data(&ob)?;
191        Ok(Self(out))
192    }
193}
194
195/// Filters for selecting Solana instructions by program ID, discriminator, and accounts.
196#[derive(Default, Debug, Clone)]
197#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
198#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
199pub struct InstructionRequest {
200    pub program_id: Vec<Address>,
201    pub discriminator: Vec<Data>,
202    pub d1: Vec<D1>,
203    pub d2: Vec<D2>,
204    pub d3: Vec<D3>,
205    pub d4: Vec<D4>,
206    pub d8: Vec<D8>,
207    pub a0: Vec<Address>,
208    pub a1: Vec<Address>,
209    pub a2: Vec<Address>,
210    pub a3: Vec<Address>,
211    pub a4: Vec<Address>,
212    pub a5: Vec<Address>,
213    pub a6: Vec<Address>,
214    pub a7: Vec<Address>,
215    pub a8: Vec<Address>,
216    pub a9: Vec<Address>,
217    pub is_committed: bool,
218    pub include_transactions: bool,
219    pub include_transaction_token_balances: bool,
220    pub include_logs: bool,
221    pub include_inner_instructions: bool,
222    pub include_blocks: bool,
223}
224
225/// Filters for selecting Solana transactions by fee payer.
226#[derive(Default, Debug, Clone)]
227#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
228pub struct TransactionRequest {
229    pub fee_payer: Vec<Address>,
230    pub include_instructions: bool,
231    pub include_logs: bool,
232    pub include_blocks: bool,
233}
234
235/// Filters for selecting Solana program logs by program ID and kind.
236#[derive(Default, Debug, Clone)]
237#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
238pub struct LogRequest {
239    pub program_id: Vec<Address>,
240    pub kind: Vec<LogKind>,
241    pub include_transactions: bool,
242    pub include_instructions: bool,
243    pub include_blocks: bool,
244}
245
246/// The type of Solana program log message.
247#[derive(Debug, Clone, Copy)]
248pub enum LogKind {
249    /// Standard program log message (sol_log).
250    Log,
251    /// Base64-encoded program data message (sol_log_data).
252    Data,
253    /// Other log message types.
254    Other,
255}
256
257impl LogKind {
258    pub fn as_str(&self) -> &str {
259        match self {
260            Self::Log => "log",
261            Self::Data => "data",
262            Self::Other => "other",
263        }
264    }
265
266    pub fn from_str(s: &str) -> Result<Self> {
267        match s {
268            "log" => Ok(Self::Log),
269            "data" => Ok(Self::Data),
270            "other" => Ok(Self::Other),
271            _ => Err(anyhow!("unknown log kind: {s}")),
272        }
273    }
274}
275
276#[cfg(feature = "pyo3")]
277impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for LogKind {
278    type Error = pyo3::PyErr;
279    fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
280        let s: &str = ob.extract().context("extract string")?;
281
282        Ok(Self::from_str(s).context("from str")?)
283    }
284}
285
286/// Filters for selecting Solana native SOL balance changes.
287#[derive(Default, Debug, Clone)]
288#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
289pub struct BalanceRequest {
290    pub account: Vec<Address>,
291    pub include_transactions: bool,
292    pub include_transaction_instructions: bool,
293    pub include_blocks: bool,
294}
295
296/// Filters for selecting Solana SPL token balance changes.
297#[derive(Default, Debug, Clone)]
298#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
299pub struct TokenBalanceRequest {
300    pub account: Vec<Address>,
301    pub pre_program_id: Vec<Address>,
302    pub post_program_id: Vec<Address>,
303    pub pre_mint: Vec<Address>,
304    pub post_mint: Vec<Address>,
305    pub pre_owner: Vec<Address>,
306    pub post_owner: Vec<Address>,
307    pub include_transactions: bool,
308    pub include_transaction_instructions: bool,
309    pub include_blocks: bool,
310}
311
312/// Filters for selecting Solana validator rewards.
313#[derive(Default, Debug, Clone)]
314#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
315pub struct RewardRequest {
316    pub pubkey: Vec<Address>,
317    pub include_blocks: bool,
318}
319
320/// Controls which columns are included in the response for each SVM table type.
321#[derive(Deserialize, Serialize, Default, Debug, Clone, Copy)]
322#[serde(default)]
323#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
324pub struct Fields {
325    pub instruction: InstructionFields,
326    pub transaction: TransactionFields,
327    pub log: LogFields,
328    pub balance: BalanceFields,
329    pub token_balance: TokenBalanceFields,
330    pub reward: RewardFields,
331    pub block: BlockFields,
332}
333
334impl Fields {
335    pub fn all() -> Self {
336        Self {
337            instruction: InstructionFields::all(),
338            transaction: TransactionFields::all(),
339            log: LogFields::all(),
340            balance: BalanceFields::all(),
341            token_balance: TokenBalanceFields::all(),
342            reward: RewardFields::all(),
343            block: BlockFields::all(),
344        }
345    }
346}
347
348/// Field selector for Solana instruction data columns.
349#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
350#[serde(default)]
351#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
352#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
353pub struct InstructionFields {
354    pub block_slot: bool,
355    pub block_hash: bool,
356    pub transaction_index: bool,
357    pub instruction_address: bool,
358    pub program_id: bool,
359    pub a0: bool,
360    pub a1: bool,
361    pub a2: bool,
362    pub a3: bool,
363    pub a4: bool,
364    pub a5: bool,
365    pub a6: bool,
366    pub a7: bool,
367    pub a8: bool,
368    pub a9: bool,
369    pub rest_of_accounts: bool,
370    pub data: bool,
371    pub d1: bool,
372    pub d2: bool,
373    pub d4: bool,
374    pub d8: bool,
375    pub error: bool,
376    pub compute_units_consumed: bool,
377    pub is_committed: bool,
378    pub has_dropped_log_messages: bool,
379}
380
381impl InstructionFields {
382    pub fn all() -> Self {
383        InstructionFields {
384            block_slot: true,
385            block_hash: true,
386            transaction_index: true,
387            instruction_address: true,
388            program_id: true,
389            a0: true,
390            a1: true,
391            a2: true,
392            a3: true,
393            a4: true,
394            a5: true,
395            a6: true,
396            a7: true,
397            a8: true,
398            a9: true,
399            rest_of_accounts: true,
400            data: true,
401            d1: true,
402            d2: true,
403            d4: true,
404            d8: true,
405            error: true,
406            compute_units_consumed: true,
407            is_committed: true,
408            has_dropped_log_messages: true,
409        }
410    }
411}
412
413/// Field selector for Solana transaction data columns.
414#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
415#[serde(default)]
416#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
417#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
418pub struct TransactionFields {
419    pub block_slot: bool,
420    pub block_hash: bool,
421    pub transaction_index: bool,
422    pub signature: bool,
423    pub version: bool,
424    pub account_keys: bool,
425    pub address_table_lookups: bool,
426    pub num_readonly_signed_accounts: bool,
427    pub num_readonly_unsigned_accounts: bool,
428    pub num_required_signatures: bool,
429    pub recent_blockhash: bool,
430    pub signatures: bool,
431    pub err: bool,
432    pub fee: bool,
433    pub compute_units_consumed: bool,
434    pub loaded_readonly_addresses: bool,
435    pub loaded_writable_addresses: bool,
436    pub fee_payer: bool,
437    pub has_dropped_log_messages: bool,
438}
439
440impl TransactionFields {
441    pub fn all() -> Self {
442        TransactionFields {
443            block_slot: true,
444            block_hash: true,
445            transaction_index: true,
446            signature: true,
447            version: true,
448            account_keys: true,
449            address_table_lookups: true,
450            num_readonly_signed_accounts: true,
451            num_readonly_unsigned_accounts: true,
452            num_required_signatures: true,
453            recent_blockhash: true,
454            signatures: true,
455            err: true,
456            fee: true,
457            compute_units_consumed: true,
458            loaded_readonly_addresses: true,
459            loaded_writable_addresses: true,
460            fee_payer: true,
461            has_dropped_log_messages: true,
462        }
463    }
464}
465
466/// Field selector for Solana log data columns.
467#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
468#[serde(default)]
469#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
470#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
471pub struct LogFields {
472    pub block_slot: bool,
473    pub block_hash: bool,
474    pub transaction_index: bool,
475    pub log_index: bool,
476    pub instruction_address: bool,
477    pub program_id: bool,
478    pub kind: bool,
479    pub message: bool,
480}
481
482impl LogFields {
483    pub fn all() -> Self {
484        LogFields {
485            block_slot: true,
486            block_hash: true,
487            transaction_index: true,
488            log_index: true,
489            instruction_address: true,
490            program_id: true,
491            kind: true,
492            message: true,
493        }
494    }
495}
496
497/// Field selector for Solana balance data columns.
498#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
499#[serde(default)]
500#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
501#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
502pub struct BalanceFields {
503    pub block_slot: bool,
504    pub block_hash: bool,
505    pub transaction_index: bool,
506    pub account: bool,
507    pub pre: bool,
508    pub post: bool,
509}
510
511impl BalanceFields {
512    pub fn all() -> Self {
513        BalanceFields {
514            block_slot: true,
515            block_hash: true,
516            transaction_index: true,
517            account: true,
518            pre: true,
519            post: true,
520        }
521    }
522}
523
524/// Field selector for Solana token balance data columns.
525#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
526#[serde(default)]
527#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
528#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
529pub struct TokenBalanceFields {
530    pub block_slot: bool,
531    pub block_hash: bool,
532    pub transaction_index: bool,
533    pub account: bool,
534    pub pre_mint: bool,
535    pub post_mint: bool,
536    pub pre_decimals: bool,
537    pub post_decimals: bool,
538    pub pre_program_id: bool,
539    pub post_program_id: bool,
540    pub pre_owner: bool,
541    pub post_owner: bool,
542    pub pre_amount: bool,
543    pub post_amount: bool,
544}
545
546impl TokenBalanceFields {
547    pub fn all() -> Self {
548        TokenBalanceFields {
549            block_slot: true,
550            block_hash: true,
551            transaction_index: true,
552            account: true,
553            pre_mint: true,
554            post_mint: true,
555            pre_decimals: true,
556            post_decimals: true,
557            pre_program_id: true,
558            post_program_id: true,
559            pre_owner: true,
560            post_owner: true,
561            pre_amount: true,
562            post_amount: true,
563        }
564    }
565}
566
567/// Field selector for Solana reward data columns.
568#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
569#[serde(default)]
570#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
571#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
572pub struct RewardFields {
573    pub block_slot: bool,
574    pub block_hash: bool,
575    pub pubkey: bool,
576    pub lamports: bool,
577    pub post_balance: bool,
578    pub reward_type: bool,
579    pub commission: bool,
580}
581
582impl RewardFields {
583    pub fn all() -> Self {
584        RewardFields {
585            block_slot: true,
586            block_hash: true,
587            pubkey: true,
588            lamports: true,
589            post_balance: true,
590            reward_type: true,
591            commission: true,
592        }
593    }
594}
595
596/// Field selector for Solana block data columns.
597#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
598#[serde(default)]
599#[cfg_attr(feature = "pyo3", derive(pyo3::FromPyObject))]
600#[expect(clippy::struct_excessive_bools, reason = "fields selection flags")]
601pub struct BlockFields {
602    pub slot: bool,
603    pub hash: bool,
604    pub parent_slot: bool,
605    pub parent_hash: bool,
606    pub height: bool,
607    pub timestamp: bool,
608}
609
610impl BlockFields {
611    pub fn all() -> Self {
612        BlockFields {
613            slot: true,
614            hash: true,
615            parent_slot: true,
616            parent_hash: true,
617            height: true,
618            timestamp: true,
619        }
620    }
621}