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, ],
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 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
249pub 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
278pub 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 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 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
354pub 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 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
487pub 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}