Skip to main content

yellowstone_vixen_mock/
lib.rs

1#![deny(
2    clippy::disallowed_methods,
3    clippy::suspicious,
4    clippy::style,
5    clippy::clone_on_ref_ptr,
6    missing_debug_implementations,
7    missing_copy_implementations
8)]
9#![warn(clippy::pedantic, missing_docs)]
10#![allow(clippy::module_name_repetitions)]
11// TODO: document everything
12#![allow(missing_docs, clippy::missing_errors_doc, clippy::missing_panics_doc)]
13
14use std::{
15    fmt::{Debug, Display},
16    fs,
17    path::{Path, PathBuf},
18    str::FromStr,
19    sync::Arc,
20};
21
22pub use futures;
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use solana_client::{nonblocking::rpc_client::RpcClient, rpc_request::RpcRequest};
26use solana_rpc_client_api::client_error::Result as ClientResult;
27use solana_sdk::{account::Account, bs58, pubkey::Pubkey, signature::Signature};
28use solana_transaction_status::{
29    option_serializer::OptionSerializer, EncodedConfirmedTransactionWithStatusMeta,
30    EncodedTransaction, EncodedTransactionWithStatusMeta, UiCompiledInstruction,
31    UiInnerInstructions, UiInstruction, UiMessage,
32};
33use yellowstone_grpc_proto::geyser::{SubscribeUpdateAccount, SubscribeUpdateAccountInfo};
34use yellowstone_vixen_core::{
35    instruction::{InstructionShared, InstructionUpdate},
36    log_messages::split_logs_by_outer_ix,
37    KeyBytes, ProgramParser,
38};
39
40const DEFAULT_RPC_ENDPOINT: &str = "https://api.devnet.solana.com";
41
42const FIXTURES_PATH: &str = "./fixtures";
43const PUBKEY_REGEX: &str = r"^[1-9A-HJ-NP-Za-km-z]{32,44}$";
44const TX_SIGNATURE_REGEX: &str = r"^[1-9A-HJ-NP-Za-km-z]{64,90}$";
45
46#[derive(Debug, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct AccountInfo {
49    pub data: Vec<u8>,
50    pub pubkey: Pubkey,
51    pub executable: bool,
52    pub lamports: u64,
53    pub owner: Pubkey,
54    pub rent_epoch: u64,
55    pub space: u64,
56}
57
58impl From<AccountInfo> for SubscribeUpdateAccount {
59    fn from(value: AccountInfo) -> Self {
60        Self {
61            is_startup: false,
62            slot: 0,
63            account: Some(SubscribeUpdateAccountInfo {
64                txn_signature: None,
65                write_version: 0,
66                pubkey: value.pubkey.to_bytes().to_vec(),
67                data: value.data.into(),
68                executable: value.executable,
69                lamports: value.lamports,
70                owner: value.owner.to_bytes().to_vec(),
71                rent_epoch: value.rent_epoch,
72            }),
73        }
74    }
75}
76
77impl TryFrom<SubscribeUpdateAccount> for AccountInfo {
78    type Error = &'static str;
79
80    fn try_from(value: SubscribeUpdateAccount) -> Result<Self, Self::Error> {
81        let account_info = value.account.ok_or("Missing account info")?;
82
83        let pubkey = Pubkey::new_from_array(
84            account_info
85                .pubkey
86                .try_into()
87                .map_err(|_| "Invalid pubkey length")?,
88        );
89
90        let owner = Pubkey::new_from_array(
91            account_info
92                .owner
93                .try_into()
94                .map_err(|_| "Invalid owner length")?,
95        );
96
97        Ok(Self {
98            pubkey,
99            data: account_info.data.to_vec(),
100            executable: account_info.executable,
101            lamports: account_info.lamports,
102            owner,
103            rent_epoch: account_info.rent_epoch,
104            space: account_info.data.len() as u64,
105        })
106    }
107}
108
109#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub struct SerializablePubkey(pub [u8; 32]);
111
112impl Debug for SerializablePubkey {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Display::fmt(self, f) }
114}
115
116impl Display for SerializablePubkey {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.write_str(&bs58::encode(&self.0).into_string())
119    }
120}
121
122impl From<KeyBytes<32>> for SerializablePubkey {
123    fn from(value: KeyBytes<32>) -> Self { Self(value.into_bytes()) }
124}
125
126impl From<SerializablePubkey> for KeyBytes<32> {
127    fn from(value: SerializablePubkey) -> Self { Self::new(value.0) }
128}
129
130pub type IxIndex = [usize; 2]; // [outer_ix_index, inner_ix_index]
131
132#[derive(Clone, Serialize, Deserialize, Debug)]
133pub struct SerializableInstructionUpdate {
134    pub ix_index: IxIndex,
135    pub program: SerializablePubkey,
136    pub accounts: Vec<SerializablePubkey>,
137    pub data: Vec<u8>,
138    pub inner: Vec<SerializableInstructionUpdate>,
139    #[serde(default)]
140    pub log_messages: Vec<String>,
141}
142
143impl From<&InstructionUpdate> for SerializableInstructionUpdate {
144    fn from(value: &InstructionUpdate) -> Self {
145        Self {
146            ix_index: [0; 2],
147            program: SerializablePubkey(value.program.0),
148            accounts: value
149                .accounts
150                .iter()
151                .map(|x| SerializablePubkey(x.into_bytes()))
152                .collect(),
153            data: value.data.clone(),
154            inner: value.inner.iter().map(Into::into).collect(),
155            log_messages: value.log_messages().to_vec(),
156        }
157    }
158}
159
160impl From<&SerializableInstructionUpdate> for InstructionUpdate {
161    fn from(value: &SerializableInstructionUpdate) -> Self {
162        let shared = Arc::new(InstructionShared {
163            log_messages: value.log_messages.clone(),
164            ..InstructionShared::default()
165        });
166
167        convert_instruction(value, &shared)
168    }
169}
170
171#[allow(clippy::cast_possible_truncation)]
172fn convert_instruction(
173    value: &SerializableInstructionUpdate,
174    shared: &Arc<InstructionShared>,
175) -> InstructionUpdate {
176    let log_range = 0..shared.log_messages.len();
177
178    InstructionUpdate {
179        program: value.program.into(),
180        accounts: value.accounts.iter().copied().map(Into::into).collect(),
181        data: value.data.clone(),
182        shared: Arc::clone(shared),
183        inner: value
184            .inner
185            .iter()
186            .map(|inner| convert_instruction(inner, shared))
187            .collect(),
188        log_range,
189        path: value
190            .ix_index
191            .iter()
192            .map(|x| *x as u32)
193            .collect::<Vec<u32>>()
194            .into(),
195    }
196}
197
198pub fn get_account_pubkey_from_index(
199    index: usize,
200    accounts: &[String],
201) -> Result<SerializablePubkey, String> {
202    if accounts.is_empty() {
203        return Err("No accounts found".to_string());
204    }
205
206    accounts.get(index).map_or(
207        Err(format!(
208            "Account index {} out of bounds for {} accounts",
209            index,
210            accounts.len(),
211        )),
212        |account| {
213            Ok(KeyBytes::<32>::from_str(account)
214                .map_err(|e| e.to_string())?
215                .into())
216        },
217    )
218}
219
220fn try_from_ui_instructions(
221    ui_ixs: &[UiCompiledInstruction],
222    accounts: &[String],
223    program_id: &str,
224) -> Result<Vec<SerializableInstructionUpdate>, String> {
225    let mut ixs: Vec<SerializableInstructionUpdate> = Vec::new();
226    for (idx, ix) in ui_ixs.iter().enumerate() {
227        let accounts_out = ix
228            .accounts
229            .iter()
230            .map(|account| get_account_pubkey_from_index(*account as usize, accounts))
231            .collect::<Result<Vec<SerializablePubkey>, String>>()?;
232        let program = get_account_pubkey_from_index(ix.program_id_index as usize, accounts)?;
233
234        let ix = SerializableInstructionUpdate {
235            ix_index: [idx, 0],
236            data: decode_bs58_to_bytes(&ix.data)?,
237            accounts: accounts_out,
238            program,
239            inner: Vec::new(),
240            log_messages: vec![],
241        };
242
243        ixs.push(ix);
244    }
245    Ok(filter_ixs(ixs, program_id))
246}
247
248fn try_from_ui_inner_ixs(
249    ui_inner_ixs: &UiInnerInstructions,
250    accounts: &[String],
251    program_id: &str,
252) -> Result<Vec<SerializableInstructionUpdate>, String> {
253    let mut ixs: Vec<SerializableInstructionUpdate> = Vec::new();
254    for (idx, ix) in ui_inner_ixs.instructions.iter().enumerate() {
255        if let UiInstruction::Compiled(compiled_ix) = ix {
256            let accounts_out = compiled_ix
257                .accounts
258                .iter()
259                .map(|account| get_account_pubkey_from_index(*account as usize, accounts))
260                .collect::<Result<Vec<SerializablePubkey>, String>>()?;
261            let program =
262                get_account_pubkey_from_index(compiled_ix.program_id_index as usize, accounts)?;
263
264            let ix = SerializableInstructionUpdate {
265                ix_index: [ui_inner_ixs.index.into(), idx],
266                data: decode_bs58_to_bytes(&compiled_ix.data)?,
267                accounts: accounts_out,
268                program,
269                inner: Vec::new(),
270                log_messages: vec![],
271            };
272            ixs.push(ix);
273        } else {
274            return Err("Invalid inner instruction".into());
275        }
276    }
277    Ok(filter_ixs(ixs, program_id))
278}
279
280fn assign_logs_to_instructions(
281    logs: &[String],
282    instructions: &mut [SerializableInstructionUpdate],
283) {
284    let per_outer = split_logs_by_outer_ix(logs);
285
286    for ix in instructions.iter_mut() {
287        let outer_idx = ix.ix_index[0];
288
289        if let Some(ix_logs) = per_outer.get(outer_idx) {
290            ix.log_messages.clone_from(ix_logs);
291        }
292    }
293}
294
295fn filter_ixs(
296    ixs: Vec<SerializableInstructionUpdate>,
297    program_id: &str,
298) -> Vec<SerializableInstructionUpdate> {
299    // Filter out instructions that matches the program
300    ixs.into_iter()
301        .filter(|ix| ix.program.to_string().eq(program_id))
302        .collect::<Vec<SerializableInstructionUpdate>>()
303}
304
305fn try_from_tx_meta<P: ProgramParser>(
306    value: EncodedConfirmedTransactionWithStatusMeta,
307    parser: &P,
308) -> Result<SerializableTransactionFixture, String> {
309    let EncodedConfirmedTransactionWithStatusMeta {
310        transaction,
311        slot: _,
312        block_time: _,
313    } = value;
314    let EncodedTransactionWithStatusMeta {
315        transaction,
316        meta,
317        version: _,
318    } = transaction;
319    let mut inner_ixs: Option<Vec<UiInnerInstructions>> = None;
320    let mut log_messages: Vec<String> = Vec::new();
321
322    let mut account_keys: Vec<String> = Vec::new();
323    let program_id = parser.program_id().to_string();
324
325    if let EncodedTransaction::Json(tx_data) = transaction {
326        if let UiMessage::Raw(raw_message) = tx_data.message {
327            account_keys.extend(raw_message.account_keys);
328
329            if let Some(meta) = meta {
330                inner_ixs = meta.inner_instructions.map(Some).flatten();
331
332                if let OptionSerializer::Some(logs) = meta.log_messages {
333                    log_messages = logs;
334                }
335
336                if let OptionSerializer::Some(loaded) = meta.loaded_addresses {
337                    for address in loaded.writable {
338                        account_keys.push(address);
339                    }
340
341                    for address in loaded.readonly {
342                        account_keys.push(address);
343                    }
344                }
345            }
346
347            // filtering outer instructions by program id
348            let mut program_filtered_ixs =
349                try_from_ui_instructions(&raw_message.instructions, &account_keys, &program_id)?;
350
351            // filtering inner instructions by program id
352            if let Some(inner_ixs) = inner_ixs
353                && !inner_ixs.is_empty()
354            {
355                for ixs in inner_ixs {
356                    let inner_ixs = try_from_ui_inner_ixs(&ixs, &account_keys, &program_id)?;
357
358                    if !inner_ixs.is_empty() {
359                        program_filtered_ixs.extend(inner_ixs);
360                    }
361                }
362            }
363
364            assign_logs_to_instructions(&log_messages, &mut program_filtered_ixs);
365
366            return Ok(SerializableTransactionFixture {
367                instructions: program_filtered_ixs,
368                log_messages,
369            });
370        }
371
372        return Err("Invalid transaction encoding".into());
373    }
374
375    Err("Invalid transaction encoding".into())
376}
377
378#[macro_export]
379macro_rules! account_fixture {
380    ($pubkey:expr, $parser:expr) => {
381        match $crate::load_fixture($pubkey, $parser).await.unwrap() {
382            $crate::FixtureData::Account(a) => {
383                $crate::run_account_parse!($parser, a)
384            },
385            f @ _ => panic!("Invalid account fixture {f:?}"),
386        }
387    };
388}
389
390#[macro_export]
391macro_rules! tx_fixture {
392    ($sig:expr, $parser:expr) => {
393        match $crate::load_fixture($sig, $parser).await.unwrap() {
394            $crate::FixtureData::Instructions(fixture) => {
395                let futures = fixture.instructions.iter().map(|ix| {
396                    let parser = $parser.clone();
397                    async move { $crate::run_ix_parse!(parser, ix) }
398                });
399                $crate::futures::future::join_all(futures).await
400            },
401            f @ _ => panic!("Invalid transaction fixture {f:?}"),
402        }
403    };
404}
405#[macro_export]
406macro_rules! run_account_parse {
407    ($parser:expr, $account:expr) => {
408        $parser.parse(&$account).await.unwrap()
409    };
410}
411
412#[macro_export]
413macro_rules! run_ix_parse {
414    ($parser:expr, $ix:expr) => {
415        match $parser.parse(&$ix.into()).await {
416            Ok(v) => Some(v),
417
418            // Ignore filtered instructions, but panic on actual errors
419            Err(yellowstone_vixen_core::ParseError::Filtered) => None,
420            Err(e) => panic!("parse error: {e:?}"),
421        }
422    };
423}
424
425pub async fn load_fixture<P: ProgramParser>(
426    fixture: &str,
427    parser: &P,
428) -> Result<FixtureData, Box<dyn std::error::Error>> {
429    maybe_create_fixture_dir()?;
430    let path = fixture_path(fixture)?;
431
432    if path.is_file() {
433        read_fixture(&path)
434    } else {
435        fetch_fixture(fixture, parser)
436            .await
437            .and_then(write_fixture(path))
438    }
439}
440
441fn convert_account_info(pubkey: Pubkey) -> impl Fn(Account) -> ClientResult<AccountInfo> {
442    move |value: Account| {
443        Ok(AccountInfo {
444            data: value.data.clone(),
445            executable: value.executable,
446            lamports: value.lamports,
447            owner: value.owner,
448            rent_epoch: value.rent_epoch,
449            space: value.data.len() as u64,
450            pubkey,
451        })
452    }
453}
454
455#[must_use]
456pub fn get_rpc_client() -> RpcClient {
457    let endpoint =
458        std::env::var("RPC_ENDPOINT").unwrap_or_else(|_| DEFAULT_RPC_ENDPOINT.to_string());
459
460    RpcClient::new(endpoint)
461}
462
463/// Serializable transaction fixture: instructions + log messages.
464///
465/// Backward-compatible: deserializing a plain JSON array (old format) yields
466/// instructions with empty log messages.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct SerializableTransactionFixture {
469    pub instructions: Vec<SerializableInstructionUpdate>,
470    #[serde(default)]
471    pub log_messages: Vec<String>,
472}
473
474#[derive(Debug, Clone)]
475pub enum FixtureData {
476    Account(SubscribeUpdateAccount),
477    Instructions(SerializableTransactionFixture),
478}
479
480async fn fetch_fixture<P: ProgramParser>(
481    fixture: &str,
482    parser: &P,
483) -> Result<FixtureData, Box<dyn std::error::Error>> {
484    let fixture_type = get_fixture_type(fixture);
485
486    match fixture_type {
487        FixtureType::Pubkey => {
488            let pubkey = Pubkey::from_str(fixture)?;
489            let rpc_client = get_rpc_client();
490
491            let account_info = rpc_client
492                .get_account(&pubkey)
493                .await
494                .and_then(convert_account_info(pubkey))?;
495
496            Ok(FixtureData::Account(SubscribeUpdateAccount::from(
497                account_info,
498            )))
499        },
500        FixtureType::Signature => {
501            let signature = Signature::from_str(fixture)?;
502            let rpc_client = get_rpc_client();
503
504            let params = json!([signature.to_string(), {
505                "encoding": "json",
506                "maxSupportedTransactionVersion": 0
507            }]);
508
509            let tx = rpc_client
510                .send(RpcRequest::GetTransaction, params)
511                .await
512                .map_err(|e| format!("Error fetching tx: {e:?}"))?;
513
514            let fixture = try_from_tx_meta(tx, parser)?;
515
516            Ok(FixtureData::Instructions(fixture))
517        },
518        FixtureType::Invalid => Err("Invalid fixture".into()),
519    }
520}
521
522fn write_fixture(
523    path: PathBuf,
524) -> impl Fn(FixtureData) -> Result<FixtureData, Box<dyn std::error::Error>> {
525    move |data: FixtureData| {
526        match data.clone() {
527            FixtureData::Account(account) => {
528                let writable = AccountInfo::try_from(account.clone())?;
529                let data = serde_json::to_string(&writable)?;
530
531                fs::write(&path, data)?;
532            },
533            FixtureData::Instructions(fixture) => {
534                let data = serde_json::to_string(&fixture)?;
535                fs::write(&path, data)?;
536            },
537        }
538        Ok(data)
539    }
540}
541
542fn maybe_create_fixture_dir() -> std::io::Result<()> {
543    let dir_exists = Path::new(FIXTURES_PATH).is_dir();
544
545    if dir_exists {
546        return Ok(());
547    }
548
549    std::fs::create_dir(FIXTURES_PATH)
550}
551
552#[derive(Debug, Clone, Copy)]
553pub enum FixtureType {
554    Pubkey,
555    Signature,
556    Invalid,
557}
558
559#[must_use]
560pub fn get_fixture_type(fixture: &str) -> FixtureType {
561    if regex::Regex::new(TX_SIGNATURE_REGEX)
562        .unwrap()
563        .is_match(fixture)
564    {
565        FixtureType::Signature
566    } else if regex::Regex::new(PUBKEY_REGEX).unwrap().is_match(fixture) {
567        FixtureType::Pubkey
568    } else {
569        FixtureType::Invalid
570    }
571}
572
573pub fn fixture_path(fixture: &str) -> Result<PathBuf, String> {
574    let mut file_name = fixture.to_string();
575    let fixture_type = get_fixture_type(fixture);
576    match fixture_type {
577        FixtureType::Pubkey => file_name.push_str("_account"),
578        FixtureType::Signature => file_name.push_str("_tx"),
579        FixtureType::Invalid => return Err("Invalid fixture".to_string()),
580    }
581    file_name.push_str(".json");
582
583    Ok(Path::new(FIXTURES_PATH).join(file_name))
584}
585
586pub fn read_account_fixture(data: &[u8]) -> Result<FixtureData, Box<dyn std::error::Error>> {
587    let account_info: AccountInfo = serde_json::from_slice(data)?;
588
589    Ok(FixtureData::Account(SubscribeUpdateAccount::from(
590        account_info,
591    )))
592}
593
594pub fn read_instructions_fixture(data: &[u8]) -> Result<FixtureData, Box<dyn std::error::Error>> {
595    // Try new format first (object with instructions + log_messages),
596    // fall back to old format (plain array of instructions).
597    let fixture: SerializableTransactionFixture =
598        serde_json::from_slice::<SerializableTransactionFixture>(data).or_else(|_| {
599            let instructions: Vec<SerializableInstructionUpdate> = serde_json::from_slice(data)?;
600
601            Ok::<_, serde_json::Error>(SerializableTransactionFixture {
602                instructions,
603                log_messages: vec![],
604            })
605        })?;
606
607    Ok(FixtureData::Instructions(fixture))
608}
609
610pub fn read_fixture(path: &Path) -> Result<FixtureData, Box<dyn std::error::Error>> {
611    let data = std::fs::read(path)?;
612
613    let fixture_type = get_fixture_type(
614        path.file_stem()
615            .ok_or("Invalid fixture path")?
616            .to_str()
617            .ok_or("Invalid fixture path")?
618            .split('_')
619            .next()
620            .ok_or("Invalid fixture path")?,
621    );
622
623    match fixture_type {
624        FixtureType::Pubkey => read_account_fixture(&data),
625        FixtureType::Signature => read_instructions_fixture(&data),
626        FixtureType::Invalid => Err("Invalid fixture".into()),
627    }
628}
629
630pub fn decode_bs58_to_bytes(bs58: &str) -> Result<Vec<u8>, String> {
631    let bytes = bs58::decode(bs58)
632        .into_vec()
633        .map_err(|e| format!("Error decoding bs58: {e:?}"))?;
634    Ok(bytes)
635}