Skip to main content

solana_stream_sdk/
txn.rs

1use std::str::FromStr;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    sync::Arc,
5};
6
7use solana_message::VersionedMessage;
8use solana_pubkey::Pubkey;
9use solana_sdk_ids::vote::id as vote_program_id;
10use solana_signature::Signature;
11use solana_transaction::versioned::VersionedTransaction;
12
13const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
14const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqPjhAG8cHpQdV3ESy1dpeBeXcAD9fQg";
15const DEFAULT_PUMPFUN_PROGRAM_ID: &str = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
16const PUMPFUN_CREATE_DISC: [u8; 8] = [0x18, 0x1e, 0xc8, 0x28, 0x05, 0x1c, 0x07, 0x77];
17const PUMPFUN_CREATE_V2_DISC: [u8; 8] = [0xd6, 0x90, 0x4c, 0xec, 0x5f, 0x8b, 0x31, 0xb4];
18const PUMPFUN_BUY_DISC: [u8; 8] = [0x66, 0x06, 0x3d, 0x12, 0x01, 0xda, 0xeb, 0xea];
19const PUMPFUN_BUY_EXACT_SOL_IN_DISC: [u8; 8] = [0x38, 0xfc, 0x74, 0x08, 0x9e, 0xdf, 0xcd, 0x5f];
20const PUMPFUN_SELL_DISC: [u8; 8] = [0x33, 0xe6, 0x85, 0xa4, 0x01, 0x7f, 0x83, 0xad];
21
22#[derive(Clone)]
23pub struct ProgramWatchConfig {
24    pub program_ids: Vec<Pubkey>,
25    pub authorities: Vec<Pubkey>,
26    pub token_program_ids: Vec<Pubkey>,
27    pub skip_vote_txs: bool,
28    pub mint_finder: Arc<dyn MintFinder + Send + Sync>,
29    pub detailers: Vec<Arc<dyn MintDetailer + Send + Sync>>,
30}
31
32impl ProgramWatchConfig {
33    pub fn new(program_ids: Vec<Pubkey>, authorities: Vec<Pubkey>) -> Self {
34        let mf = Arc::new(default_mint_finder(&program_ids));
35        Self {
36            program_ids: program_ids.clone(),
37            authorities,
38            token_program_ids: default_token_program_ids(),
39            skip_vote_txs: true,
40            mint_finder: mf.clone(),
41            detailers: default_detailers_from_programs(&program_ids),
42        }
43    }
44
45    pub fn with_token_program_ids(mut self, token_program_ids: Vec<Pubkey>) -> Self {
46        self.token_program_ids = token_program_ids;
47        self
48    }
49
50    pub fn with_skip_vote_txs(mut self, skip_vote_txs: bool) -> Self {
51        self.skip_vote_txs = skip_vote_txs;
52        self
53    }
54
55    pub fn with_mint_finder(mut self, mint_finder: Arc<dyn MintFinder + Send + Sync>) -> Self {
56        self.mint_finder = mint_finder;
57        self
58    }
59
60    pub fn with_detailers(mut self, detailers: Vec<Arc<dyn MintDetailer + Send + Sync>>) -> Self {
61        self.detailers = detailers;
62        self
63    }
64}
65
66#[derive(Debug, Clone)]
67pub struct ProgramHit {
68    pub signature: Signature,
69    pub program_hit: bool,
70    pub authority_hit: bool,
71    pub mints: Vec<MintInfo>,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
75pub struct MintInfo {
76    pub mint: Pubkey,
77    pub label: Option<&'static str>,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct MintDetail {
82    pub mint: Pubkey,
83    pub label: Option<&'static str>,
84    pub action: Option<&'static str>,
85    pub sol_amount: Option<u64>,
86    pub token_amount: Option<u64>,
87    pub name: Option<String>,
88    pub symbol: Option<String>,
89    pub uri: Option<String>,
90}
91
92pub fn parse_pubkeys_env(var: &str, defaults: &[&str]) -> Vec<Pubkey> {
93    let raw = std::env::var(var).ok();
94    parse_pubkeys(raw.as_deref(), defaults)
95}
96
97pub fn parse_pubkeys(raw: Option<&str>, defaults: &[&str]) -> Vec<Pubkey> {
98    let mut set = BTreeSet::new();
99
100    match raw {
101        Some(value) => {
102            for part in value.split(',') {
103                let trimmed = part.trim();
104                if trimmed.is_empty() {
105                    continue;
106                }
107                if let Ok(pk) = Pubkey::from_str(trimmed) {
108                    set.insert(pk);
109                }
110            }
111        }
112        None => {
113            for def in defaults {
114                if let Ok(pk) = Pubkey::from_str(def) {
115                    set.insert(pk);
116                }
117            }
118        }
119    }
120
121    set.into_iter().collect()
122}
123
124pub fn default_token_program_ids() -> Vec<Pubkey> {
125    parse_pubkeys(
126        None,
127        &[
128            TOKEN_PROGRAM_ID,
129            TOKEN_2022_PROGRAM_ID, //
130        ],
131    )
132}
133
134pub fn is_vote_transaction(tx: &VersionedTransaction) -> bool {
135    let keys = tx.message.static_account_keys();
136    tx.message.instructions().iter().any(|ix| {
137        keys.get(ix.program_id_index as usize)
138            .map(|pid| pid == &vote_program_id())
139            .unwrap_or(false)
140    })
141}
142
143pub fn detect_program_hit(
144    tx: &VersionedTransaction,
145    cfg: &ProgramWatchConfig,
146) -> Option<ProgramHit> {
147    if cfg.program_ids.is_empty() && cfg.authorities.is_empty() {
148        return None;
149    }
150    let keys = tx.message.static_account_keys();
151    let authority_hit = !cfg.authorities.is_empty()
152        && keys
153            .iter()
154            .any(|key| cfg.authorities.iter().any(|auth| auth == key));
155    let mut program_hit = false;
156
157    for ix in tx.message.instructions() {
158        let Some(program_id) = keys.get(ix.program_id_index as usize) else {
159            continue;
160        };
161        if cfg.skip_vote_txs && *program_id == vote_program_id() {
162            return None;
163        }
164        if !program_hit && cfg.program_ids.iter().any(|want| want == program_id) {
165            program_hit = true;
166        }
167    }
168
169    let mint_accounts: Vec<MintInfo> = cfg.mint_finder.find_mints(tx, cfg);
170    if !program_hit && !authority_hit {
171        return None;
172    }
173
174    Some(ProgramHit {
175        signature: tx.signatures.get(0).cloned().unwrap_or_default(),
176        program_hit,
177        authority_hit,
178        mints: mint_accounts,
179    })
180}
181
182pub fn extract_mint_accounts(
183    message: &VersionedMessage,
184    token_program_ids: &[Pubkey],
185) -> Vec<Pubkey> {
186    let mut mints = BTreeSet::new();
187    let keys = message.static_account_keys();
188    for ix in message.instructions() {
189        let Some(program_id) = keys.get(ix.program_id_index as usize) else {
190            continue;
191        };
192        if !token_program_ids.iter().any(|id| id == program_id) {
193            continue;
194        }
195        let Some(tag) = ix.data.first() else {
196            continue;
197        };
198        // Token program: 0 InitializeMint, 7 MintTo, 14 InitializeMint2, 20 InitializeMintCloseAuthority
199        if matches!(tag, 0 | 7 | 14 | 20) {
200            if let Some(mint_idx) = ix.accounts.get(0) {
201                if let Some(mint) = keys.get(*mint_idx as usize) {
202                    mints.insert(*mint);
203                }
204            }
205        }
206    }
207    mints.into_iter().collect()
208}
209
210pub fn first_signatures<'a, I>(txs: I, limit: usize, skip_vote_txs: bool) -> Vec<Signature>
211where
212    I: IntoIterator<Item = &'a VersionedTransaction>,
213{
214    txs.into_iter()
215        .filter(|tx| !(skip_vote_txs && is_vote_transaction(tx)))
216        .filter_map(|tx| tx.signatures.get(0))
217        .take(limit)
218        .cloned()
219        .collect()
220}
221
222pub fn fmt_pubkeys(pubkeys: &[Pubkey]) -> Vec<String> {
223    pubkeys.iter().map(|p| p.to_string()).collect()
224}
225
226pub trait MintFinder {
227    fn find_mints(&self, tx: &VersionedTransaction, cfg: &ProgramWatchConfig) -> Vec<MintInfo>;
228}
229
230pub trait MintDetailer {
231    fn detail(
232        &self,
233        tx: &VersionedTransaction,
234        cfg: &ProgramWatchConfig,
235        mints: &[MintInfo],
236    ) -> Vec<MintDetail>;
237}
238
239fn insert_mint(map: &mut BTreeMap<Pubkey, MintInfo>, mint: Pubkey, label: Option<&'static str>) {
240    map.entry(mint)
241        .and_modify(|info| {
242            if info.label.is_none() && label.is_some() {
243                info.label = label;
244            }
245        })
246        .or_insert(MintInfo { mint, label });
247}
248
249/// Default SPL Token/Token-2022 finder: looks for Initialize/MintTo tags at accounts[0].
250pub struct SplTokenMintFinder;
251
252impl MintFinder for SplTokenMintFinder {
253    fn find_mints(&self, tx: &VersionedTransaction, cfg: &ProgramWatchConfig) -> Vec<MintInfo> {
254        let mut mint_accounts = BTreeMap::new();
255        let keys = tx.message.static_account_keys();
256        for ix in tx.message.instructions() {
257            let Some(program_id) = keys.get(ix.program_id_index as usize) else {
258                continue;
259            };
260            if !cfg.token_program_ids.iter().any(|id| id == program_id) {
261                continue;
262            }
263            let Some(tag) = ix.data.first() else {
264                continue;
265            };
266            if matches!(tag, 0 | 7 | 14 | 20) {
267                if let Some(mint_idx) = ix.accounts.get(0) {
268                    if let Some(mint) = keys.get(*mint_idx as usize) {
269                        insert_mint(&mut mint_accounts, *mint, Some("spl-token"));
270                    }
271                }
272            }
273        }
274        mint_accounts.into_values().collect()
275    }
276}
277
278/// Pump.fun finder: picks mint from pump.fun instructions by account position (create_v2/buy/sell).
279pub struct PumpfunAccountMintFinder {
280    pumpfun_ids: Vec<Pubkey>,
281}
282
283impl PumpfunAccountMintFinder {
284    pub fn new(pumpfun_ids: Vec<Pubkey>) -> Self {
285        Self { pumpfun_ids }
286    }
287}
288
289fn is_system_id(pk: &Pubkey) -> bool {
290    // Avoid depending on solana_sdk::system_program in this build profile.
291    pk.to_string() == "11111111111111111111111111111111"
292}
293
294impl MintFinder for PumpfunAccountMintFinder {
295    fn find_mints(&self, tx: &VersionedTransaction, _cfg: &ProgramWatchConfig) -> Vec<MintInfo> {
296        let keys = tx.message.static_account_keys();
297        let mut mints = BTreeMap::new();
298        for ix in tx.message.instructions() {
299            let Some(program_id) = keys.get(ix.program_id_index as usize) else {
300                continue;
301            };
302            if !self.pumpfun_ids.iter().any(|id| id == program_id) {
303                continue;
304            }
305            let kind = match ix.data.get(0..8) {
306                Some(bytes) if bytes == PUMPFUN_CREATE_V2_DISC || bytes == PUMPFUN_CREATE_DISC => {
307                    Some("pump:create")
308                }
309                Some(bytes) if bytes == PUMPFUN_BUY_DISC => Some("pump:buy"),
310                Some(bytes) if bytes == PUMPFUN_BUY_EXACT_SOL_IN_DISC => Some("pump:buy_exact"),
311                Some(bytes) if bytes == PUMPFUN_SELL_DISC => Some("pump:sell"),
312                _ => None,
313            };
314
315            match kind {
316                Some("pump:create") => {
317                    if let Some(mint_idx) = ix.accounts.get(0) {
318                        if let Some(mint) = keys.get(*mint_idx as usize) {
319                            if !is_system_id(mint) {
320                                insert_mint(&mut mints, *mint, Some("pump:create"));
321                            }
322                        }
323                    }
324                }
325                Some("pump:buy") | Some("pump:buy_exact") | Some("pump:sell") => {
326                    if ix.accounts.len() > 2 {
327                        if let Some(mint_idx) = ix.accounts.get(2) {
328                            if let Some(mint) = keys.get(*mint_idx as usize) {
329                                if !is_system_id(mint) {
330                                    insert_mint(&mut mints, *mint, kind);
331                                }
332                            }
333                        }
334                    }
335                }
336                _ => {
337                    // Fallback: keep a generic trade tag if we can't classify (e.g., new ix name)
338                    if ix.accounts.len() > 2 {
339                        if let Some(mint_idx) = ix.accounts.get(2) {
340                            if let Some(mint) = keys.get(*mint_idx as usize) {
341                                if !is_system_id(mint) {
342                                    insert_mint(&mut mints, *mint, Some("pump:trade"));
343                                }
344                            }
345                        }
346                    }
347                }
348            }
349        }
350        mints.into_values().collect()
351    }
352}
353
354/// Pump.fun detailer: adds action metadata based on label.
355pub struct PumpfunDetailer {
356    pumpfun_ids: Vec<Pubkey>,
357}
358
359impl PumpfunDetailer {
360    pub fn new(pumpfun_ids: Vec<Pubkey>) -> Self {
361        Self { pumpfun_ids }
362    }
363}
364
365impl MintDetailer for PumpfunDetailer {
366    fn detail(
367        &self,
368        tx: &VersionedTransaction,
369        _cfg: &ProgramWatchConfig,
370        mints: &[MintInfo],
371    ) -> Vec<MintDetail> {
372        let keys = tx.message.static_account_keys();
373        let mut out = BTreeMap::new();
374        for ix in tx.message.instructions() {
375            let Some(program_id) = keys.get(ix.program_id_index as usize) else {
376                continue;
377            };
378            if !self.pumpfun_ids.iter().any(|id| id == program_id) {
379                continue;
380            }
381            let disc = ix.data.get(0..8);
382            let kind = match disc {
383                Some(bytes) if bytes == PUMPFUN_CREATE_V2_DISC || bytes == PUMPFUN_CREATE_DISC => {
384                    Some("create")
385                }
386                Some(bytes) if bytes == PUMPFUN_BUY_DISC => Some("buy"),
387                Some(bytes) if bytes == PUMPFUN_BUY_EXACT_SOL_IN_DISC => Some("buy_exact"),
388                Some(bytes) if bytes == PUMPFUN_SELL_DISC => Some("sell"),
389                _ => None,
390            };
391            let mint_idx = match kind {
392                Some("create") => ix.accounts.get(0),
393                _ => ix.accounts.get(2),
394            };
395            let Some(mint_idx) = mint_idx else {
396                continue;
397            };
398            let Some(mint) = keys.get(*mint_idx as usize) else {
399                continue;
400            };
401            if is_system_id(mint) {
402                continue;
403            }
404            if kind.is_none() {
405                // Unknown discriminator: skip to avoid bogus values.
406                continue;
407            }
408            let (token_amount, sol_amount) = match kind {
409                Some("create") => (None, None),
410                Some("buy_exact") => (
411                    None,
412                    ix.data
413                        .get(8..16)
414                        .and_then(|b| b.try_into().ok())
415                        .map(u64::from_le_bytes),
416                ),
417                _ => (
418                    ix.data
419                        .get(8..16)
420                        .and_then(|b| b.try_into().ok())
421                        .map(u64::from_le_bytes),
422                    ix.data
423                        .get(16..24)
424                        .and_then(|b| b.try_into().ok())
425                        .map(u64::from_le_bytes),
426                ),
427            };
428            let (action, label) = match kind {
429                Some("create") => (Some("create"), Some("pump:create")),
430                Some("buy") => (Some("buy"), Some("pump:buy")),
431                Some("buy_exact") => (Some("buy"), Some("pump:buy_exact")),
432                Some("sell") => (Some("sell"), Some("pump:sell")),
433                _ => (None, None),
434            };
435            let entry = out.entry(*mint).or_insert(MintDetail {
436                mint: *mint,
437                label,
438                action,
439                sol_amount,
440                token_amount,
441                name: None,
442                symbol: None,
443                uri: None,
444            });
445            if let Some(k) = action {
446                if entry.action.is_none() || entry.action == Some("create") {
447                    entry.action = Some(k);
448                    entry.label = label;
449                }
450            }
451            if sol_amount.is_some() {
452                entry.sol_amount = sol_amount;
453            }
454            if token_amount.is_some() {
455                entry.token_amount = token_amount;
456            }
457        }
458        for m in mints {
459            if !out.contains_key(&m.mint) {
460                let action = m.label.map(|l| {
461                    let trimmed = l.trim_start_matches("pump:");
462                    match trimmed {
463                        "trade" => "sell",
464                        "buy_exact" => "buy",
465                        other => other,
466                    }
467                });
468                out.insert(
469                    m.mint,
470                    MintDetail {
471                        mint: m.mint,
472                        label: m.label,
473                        action,
474                        sol_amount: None,
475                        token_amount: None,
476                        name: None,
477                        symbol: None,
478                        uri: None,
479                    },
480                );
481            }
482        }
483        out.into_values().collect()
484    }
485}
486
487/// Composite finder: aggregates multiple strategies.
488pub struct CompositeMintFinder {
489    finders: Vec<Arc<dyn MintFinder + Send + Sync>>,
490}
491
492impl CompositeMintFinder {
493    pub fn new(finders: Vec<Arc<dyn MintFinder + Send + Sync>>) -> Self {
494        Self { finders }
495    }
496}
497
498impl MintFinder for CompositeMintFinder {
499    fn find_mints(&self, tx: &VersionedTransaction, cfg: &ProgramWatchConfig) -> Vec<MintInfo> {
500        let mut out = BTreeMap::new();
501        for f in &self.finders {
502            for m in f.find_mints(tx, cfg) {
503                insert_mint(&mut out, m.mint, m.label);
504            }
505        }
506        out.into_values().collect()
507    }
508}
509
510fn default_mint_finder(program_ids: &[Pubkey]) -> CompositeMintFinder {
511    let mut pumpfun_ids: Vec<Pubkey> = program_ids.to_vec();
512    if pumpfun_ids.is_empty() {
513        if let Ok(id) = Pubkey::from_str(DEFAULT_PUMPFUN_PROGRAM_ID) {
514            pumpfun_ids.push(id);
515        }
516    }
517    CompositeMintFinder::new(vec![
518        Arc::new(PumpfunAccountMintFinder::new(pumpfun_ids.clone())),
519        Arc::new(SplTokenMintFinder),
520    ])
521}
522
523pub fn default_detailers_from_programs(
524    program_ids: &[Pubkey],
525) -> Vec<Arc<dyn MintDetailer + Send + Sync>> {
526    let mut pumpfun_ids: Vec<Pubkey> = program_ids.to_vec();
527    if pumpfun_ids.is_empty() {
528        if let Ok(id) = Pubkey::from_str(DEFAULT_PUMPFUN_PROGRAM_ID) {
529            pumpfun_ids.push(id);
530        }
531    }
532    vec![Arc::new(PumpfunDetailer::new(pumpfun_ids))]
533}