ferrox_actions/birdeye/
client.rs

1use reqwest::{
2    header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE},
3    Client,
4};
5use solana_sdk::pubkey::Pubkey;
6
7const BASE_URL: &str = "https://public-api.birdeye.so";
8
9#[derive(Debug, Clone)]
10pub struct BirdeyeClient {
11    api_key: String,
12    client: Client,
13}
14
15impl BirdeyeClient {
16    pub fn new(api_key: String) -> Self {
17        Self {
18            api_key,
19            client: Client::new(),
20        }
21    }
22
23    fn get_headers(&self) -> HeaderMap {
24        let mut headers = HeaderMap::new();
25        headers.insert("X-API-KEY", HeaderValue::from_str(&self.api_key).unwrap());
26        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
27        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
28        headers
29    }
30
31    async fn make_request(&self, endpoint: &str) -> Result<String, String> {
32        let url = format!("{}{}", BASE_URL, endpoint);
33        println!("Making request to {}", url);
34        let response = self
35            .client
36            .get(&url)
37            .headers(self.get_headers())
38            .send()
39            .await
40            .map_err(|e| e.to_string())?;
41
42        if response.status().is_success() {
43            response.text().await.map_err(|e| e.to_string())
44        } else {
45            Err(format!("Request failed with status: {}", response.status()))
46        }
47    }
48
49    fn format_resolution(resolution: String) -> String {
50        // If resolution is just a number, append "M"
51        if resolution.chars().all(|c| c.is_numeric()) {
52            format!("{}m", resolution)
53        } else {
54            resolution
55        }
56    }
57
58    fn validate_solana_address(address: &str) -> Result<Pubkey, String> {
59        address
60            .parse::<Pubkey>()
61            .map_err(|e| format!("Invalid Solana address: {}", e))
62    }
63
64    pub async fn get_token_price(&self, address: String) -> Result<String, String> {
65        let pubkey = Self::validate_solana_address(&address)?;
66        self.make_request(&format!("/defi/price?address={}", pubkey.to_string()))
67            .await
68    }
69
70    pub async fn get_token_price_history(
71        &self,
72        address: String,
73        resolution: String,
74        time_from: Option<i64>,
75        time_to: Option<i64>,
76        limit: Option<i32>,
77    ) -> Result<String, String> {
78        let pubkey = Self::validate_solana_address(&address)?;
79        let formatted_resolution = Self::format_resolution(resolution);
80        let mut endpoint = format!(
81            "/defi/history_price?address={}&address_type=token&type={}",
82            pubkey.to_string(),
83            formatted_resolution
84        );
85
86        if let Some(from) = time_from {
87            endpoint.push_str(&format!("&time_from={}", from));
88        }
89        if let Some(to) = time_to {
90            endpoint.push_str(&format!("&time_to={}", to));
91        }
92        if let Some(limit) = limit {
93            endpoint.push_str(&format!("&limit={}", limit));
94        }
95        self.make_request(&endpoint).await
96    }
97
98    pub async fn get_multi_token_price(&self, addresses: String) -> Result<String, String> {
99        let pubkeys: Result<Vec<Pubkey>, String> = addresses
100            .split(',')
101            .map(|addr| Self::validate_solana_address(addr.trim()))
102            .collect();
103        let pubkeys = pubkeys?;
104
105        let formatted_addresses = pubkeys
106            .iter()
107            .map(|pubkey| pubkey.to_string())
108            .collect::<Vec<String>>()
109            .join(",");
110
111        self.make_request(&format!(
112            "/defi/multi_price?list_address={}",
113            formatted_addresses
114        ))
115        .await
116    }
117
118    pub async fn get_token_trending(&self, limit: Option<i32>) -> Result<String, String> {
119        let mut endpoint = "/defi/token_trending".to_string();
120        if let Some(limit) = limit {
121            endpoint.push_str(&format!("?limit={}", limit));
122        }
123        self.make_request(&endpoint).await
124    }
125
126    pub async fn get_token_ohlcv(
127        &self,
128        address: String,
129        resolution: String,
130        time_from: i64,
131        time_to: i64,
132    ) -> Result<String, String> {
133        let pubkey = Self::validate_solana_address(&address)?;
134        let formatted_resolution = Self::format_resolution(resolution);
135        self.make_request(&format!(
136            "/defi/ohlcv?address={}&type={}&time_from={}&time_to={}",
137            pubkey.to_string(),
138            formatted_resolution,
139            time_from,
140            time_to
141        ))
142        .await
143    }
144
145    pub async fn get_pair_ohlcv(
146        &self,
147        pair_address: String,
148        resolution: String,
149        time_from: i64,
150        time_to: i64,
151    ) -> Result<String, String> {
152        let pubkey = Self::validate_solana_address(&pair_address)?;
153        let formatted_resolution = Self::format_resolution(resolution);
154        self.make_request(&format!(
155            "/defi/ohlcv/pair?address={}&type={}&time_from={}&time_to={}",
156            pubkey.to_string(),
157            formatted_resolution,
158            time_from,
159            time_to
160        ))
161        .await
162    }
163
164    pub async fn get_token_trades(
165        &self,
166        address: String,
167        limit: Option<i32>,
168        offset: Option<i32>,
169    ) -> Result<String, String> {
170        let pubkey = Self::validate_solana_address(&address)?;
171        println!("Pubkey: {:?}", pubkey);
172        let mut endpoint = format!(
173            "/defi/txs/token?address={}&sort_type=desc",
174            pubkey.to_string()
175        );
176        if let Some(limit) = limit {
177            endpoint.push_str(&format!("&limit={}", limit));
178        }
179        if let Some(offset) = offset {
180            endpoint.push_str(&format!("&offset={}", offset));
181        }
182        self.make_request(&endpoint).await
183    }
184
185    pub async fn get_pair_trades(
186        &self,
187        pair_address: String,
188        limit: Option<i32>,
189        offset: Option<i32>,
190    ) -> Result<String, String> {
191        let pubkey = Self::validate_solana_address(&pair_address)?;
192        println!("Pubkey: {:?}", pubkey);
193        let mut endpoint = format!(
194            "/defi/txs/pair?address={}&tx_type=swap&sort_type=desc",
195            pubkey.to_string()
196        );
197        if let Some(limit) = limit {
198            if limit >= 50 {
199                endpoint.push_str("&limit=50");
200            } else {
201                endpoint.push_str(&format!("&limit={}", limit));
202            }
203        }
204        if let Some(offset) = offset {
205            endpoint.push_str(&format!("&offset={}", offset));
206        }
207        self.make_request(&endpoint).await
208    }
209
210    pub async fn get_token_overview(&self, address: String) -> Result<String, String> {
211        let pubkey = Self::validate_solana_address(&address)?;
212        self.make_request(&format!(
213            "/defi/token_overview?address={}",
214            pubkey.to_string()
215        ))
216        .await
217    }
218
219    pub async fn get_token_list(
220        &self,
221        limit: Option<i32>,
222        offset: Option<i32>,
223    ) -> Result<String, String> {
224        let mut endpoint = "/defi/tokenList".to_string();
225        let mut has_param = false;
226        if let Some(limit) = limit {
227            endpoint.push_str(&format!("?limit={}", limit));
228            has_param = true;
229        }
230        if let Some(offset) = offset {
231            endpoint.push_str(&format!(
232                "{}offset={}",
233                if has_param { "&" } else { "?" },
234                offset
235            ));
236        }
237        self.make_request(&endpoint).await
238    }
239
240    pub async fn get_token_security(&self, address: String) -> Result<String, String> {
241        let pubkey = Self::validate_solana_address(&address)?;
242        self.make_request(&format!(
243            "/defi/token_security?address={}",
244            pubkey.to_string()
245        ))
246        .await
247    }
248
249    pub async fn get_token_market_list(&self, address: String) -> Result<String, String> {
250        let pubkey = Self::validate_solana_address(&address)?;
251        self.make_request(&format!("/defi/v2/markets?address={}", pubkey.to_string()))
252            .await
253    }
254
255    pub async fn get_token_new_listing(
256        &self,
257        limit: Option<i32>,
258        offset: Option<i32>,
259    ) -> Result<String, String> {
260        let mut endpoint = "/defi/v2/tokens/new_listing".to_string();
261        let mut has_param = false;
262        if let Some(limit) = limit {
263            endpoint.push_str(&format!("?limit={}", limit));
264            has_param = true;
265        }
266        if let Some(offset) = offset {
267            endpoint.push_str(&format!(
268                "{}offset={}",
269                if has_param { "&" } else { "?" },
270                offset
271            ));
272        }
273        self.make_request(&endpoint).await
274    }
275
276    pub async fn get_token_top_traders(
277        &self,
278        address: String,
279        limit: Option<i32>,
280    ) -> Result<String, String> {
281        let pubkey = Self::validate_solana_address(&address)?;
282        let mut endpoint = format!("/defi/v2/tokens/top_traders?address={}", pubkey.to_string());
283        if let Some(limit) = limit {
284            endpoint.push_str(&format!("&limit={}", limit));
285        }
286        self.make_request(&endpoint).await
287    }
288
289    // Trader endpoints
290    pub async fn get_gainers_losers(&self) -> Result<String, String> {
291        self.make_request("/trader/gainers-losers").await
292    }
293
294    pub async fn get_trader_txs_by_time(
295        &self,
296        address: String,
297        time_from: i64,
298        time_to: i64,
299        limit: Option<i32>,
300    ) -> Result<String, String> {
301        let pubkey = Self::validate_solana_address(&address)?;
302        let mut endpoint = format!(
303            "/trader/txs/seek_by_time?address={}&from={}&to={}",
304            pubkey.to_string(),
305            time_from,
306            time_to
307        );
308        if let Some(limit) = limit {
309            endpoint.push_str(&format!("&limit={}", limit));
310        }
311        self.make_request(&endpoint).await
312    }
313
314    // Wallet endpoints
315    pub async fn list_supported_chains(&self) -> Result<String, String> {
316        self.make_request("/v1/wallet/list_supported_chain").await
317    }
318
319    pub async fn get_wallet_portfolio(
320        &self,
321        wallet_address: String,
322        chain_id: String,
323    ) -> Result<String, String> {
324        self.make_request(&format!(
325            "/v1/wallet/token_list?wallet={}&chain_id={}",
326            wallet_address, chain_id
327        ))
328        .await
329    }
330
331    pub async fn get_wallet_portfolio_multichain(
332        &self,
333        wallet_address: String,
334    ) -> Result<String, String> {
335        self.make_request(&format!(
336            "/v1/wallet/multichain_token_list?wallet={}",
337            wallet_address
338        ))
339        .await
340    }
341
342    // pub async fn get_wallet_token_balance(
343    //     &self,
344    //     wallet_address: String,
345    //     token_address: String,
346    //     chain_id: String,
347    // ) -> Result<String, String> {
348    //     self.make_request(&format!(
349    //         "/v1/wallet/token_balance?wallet={}&token_address={:?}&chain_id={}",
350    //         wallet_address, token_address, chain_id
351    //     ))
352    //     .await
353    // }
354
355    pub async fn get_wallet_transaction_history(
356        &self,
357        wallet_address: String,
358        chain_id: String,
359        limit: Option<i32>,
360        offset: Option<i32>,
361    ) -> Result<String, String> {
362        let mut endpoint = format!(
363            "/v1/wallet/tx_list?wallet={}&chain_id={}",
364            wallet_address, chain_id
365        );
366        if let Some(limit) = limit {
367            endpoint.push_str(&format!("&limit={}", limit));
368        }
369        if let Some(offset) = offset {
370            endpoint.push_str(&format!("&offset={}", offset));
371        }
372        self.make_request(&endpoint).await
373    }
374
375    pub async fn get_wallet_transaction_history_multichain(
376        &self,
377        wallet_address: String,
378        limit: Option<i32>,
379        offset: Option<i32>,
380    ) -> Result<String, String> {
381        let mut endpoint = format!("/v1/wallet/multichain_tx_list?wallet={}", wallet_address);
382        if let Some(limit) = limit {
383            endpoint.push_str(&format!("&limit={}", limit));
384        }
385        if let Some(offset) = offset {
386            endpoint.push_str(&format!("&offset={}", offset));
387        }
388        self.make_request(&endpoint).await
389    }
390
391    pub async fn simulate_transaction(
392        &self,
393        chain_id: String,
394        tx_data: String,
395    ) -> Result<String, String> {
396        self.make_request(&format!(
397            "/v1/wallet/simulate?chain_id={}&tx_data={}",
398            chain_id, tx_data
399        ))
400        .await
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn setup_client() -> BirdeyeClient {
409        let api_key = std::env::var("BIRDEYE_API_KEY")
410            .expect("BIRDEYE_API_KEY must be set in .env for tests");
411        BirdeyeClient::new(api_key)
412    }
413
414    const SOL_ADDRESS: &str = "So11111111111111111111111111111111111111112";
415    const USDC_ADDRESS: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
416    const TEST_WALLET: &str = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"; // Example Solana wallet
417    const TEST_CHAIN_ID: &str = "solana";
418
419    #[tokio::test]
420    async fn test_get_token_price() {
421        let client = setup_client();
422        let result = client.get_token_price(SOL_ADDRESS.to_string()).await;
423        println!("Token price result: {:?}", result);
424        assert!(result.is_ok());
425    }
426
427    #[tokio::test]
428    async fn test_get_token_price_history() {
429        let client = setup_client();
430        let result = client
431            .get_token_price_history(
432                SOL_ADDRESS.to_string(),
433                "15m".to_string(),
434                Some(1677652288),
435                Some(1677738688),
436                Some(100),
437            )
438            .await;
439        println!("Price history result: {:?}", result);
440        assert!(result.is_ok());
441    }
442
443    #[tokio::test]
444    async fn test_get_multi_token_price() {
445        let client = setup_client();
446        let addresses = format!("{},{}", SOL_ADDRESS, USDC_ADDRESS);
447        let result = client.get_multi_token_price(addresses).await;
448        println!("Multi token price result: {:?}", result);
449        assert!(result.is_ok());
450    }
451
452    #[tokio::test]
453    async fn test_get_token_ohlcv() {
454        let client = setup_client();
455        let result = client
456            .get_token_ohlcv(
457                SOL_ADDRESS.to_string(),
458                "1D".to_string(),
459                1677652288,
460                1677738688,
461            )
462            .await;
463        println!("OHLCV result: {:?}", result);
464        assert!(result.is_ok());
465    }
466
467    #[tokio::test]
468    async fn test_get_pair_ohlcv() {
469        let client = setup_client();
470        let result = client
471            .get_pair_ohlcv(
472                "8HoQnePLqPj4M7PUDzfw8e3Ymdwgc7NLGnaTUapubyvu".to_string(), // SOL/USDC pair
473                "1D".to_string(),
474                1677652288,
475                1677738688,
476            )
477            .await;
478        println!("Pair OHLCV result: {:?}", result);
479        assert!(result.is_ok());
480    }
481
482    #[tokio::test]
483    async fn test_get_token_trades() {
484        let client = setup_client();
485        let result = client
486            .get_token_trades(SOL_ADDRESS.to_string(), Some(10), Some(0))
487            .await;
488        println!("Token trades result: {:?}", result);
489        assert!(result.is_ok());
490    }
491
492    #[tokio::test]
493    async fn test_get_pair_trades() {
494        let client = setup_client();
495        let result = client
496            .get_pair_trades(
497                "8HoQnePLqPj4M7PUDzfw8e3Ymdwgc7NLGnaTUapubyvu".to_string(),
498                Some(10),
499                Some(0),
500            )
501            .await;
502        println!("Pair trades result: {:?}", result);
503        assert!(result.is_ok());
504    }
505
506    #[tokio::test]
507    async fn test_get_token_overview() {
508        let client = setup_client();
509        let result = client.get_token_overview(SOL_ADDRESS.to_string()).await;
510        println!("Token overview result: {:?}", result);
511        assert!(result.is_ok());
512    }
513
514    #[tokio::test]
515    async fn test_get_token_list() {
516        let client = setup_client();
517        let result = client.get_token_list(Some(10), Some(0)).await;
518        println!("Token list result: {:?}", result);
519        assert!(result.is_ok());
520    }
521
522    #[tokio::test]
523    async fn test_get_token_security() {
524        let client = setup_client();
525        let result = client.get_token_security(SOL_ADDRESS.to_string()).await;
526        println!("Token security result: {:?}", result);
527        assert!(result.is_ok());
528    }
529
530    #[tokio::test]
531    async fn test_get_token_market_list() {
532        let client = setup_client();
533        let result = client.get_token_market_list(SOL_ADDRESS.to_string()).await;
534        println!("Market list result: {:?}", result);
535        assert!(result.is_ok());
536    }
537
538    #[tokio::test]
539    async fn test_get_token_new_listing() {
540        let client = setup_client();
541        let result = client.get_token_new_listing(Some(10), Some(0)).await;
542        println!("New listing result: {:?}", result);
543        assert!(result.is_ok());
544    }
545
546    #[tokio::test]
547    async fn test_get_token_top_traders() {
548        let client = setup_client();
549        let result = client
550            .get_token_top_traders(SOL_ADDRESS.to_string(), Some(10))
551            .await;
552        println!("Top traders result: {:?}", result);
553        assert!(result.is_ok());
554    }
555
556    #[tokio::test]
557    async fn test_get_token_trending() {
558        let client = setup_client();
559        let result = client.get_token_trending(Some(10)).await;
560        println!("Trending result: {:?}", result);
561        assert!(result.is_ok());
562    }
563
564    #[tokio::test]
565    async fn test_get_gainers_losers() {
566        let client = setup_client();
567        let result = client.get_gainers_losers().await;
568        println!("Gainers/Losers result: {:?}", result);
569        assert!(result.is_ok());
570    }
571
572    #[tokio::test]
573    async fn test_get_trader_txs_by_time() {
574        let client = setup_client();
575        let result = client
576            .get_trader_txs_by_time(SOL_ADDRESS.to_string(), 1677652288, 1677738688, Some(10))
577            .await;
578        println!("Trader txs result: {:?}", result);
579        assert!(result.is_ok());
580    }
581
582    #[tokio::test]
583    async fn test_list_supported_chains() {
584        let client = setup_client();
585        let result = client.list_supported_chains().await;
586        println!("Supported chains result: {:?}", result);
587        assert!(result.is_ok());
588    }
589
590    #[tokio::test]
591    async fn test_get_wallet_portfolio() {
592        let client = setup_client();
593        let result = client
594            .get_wallet_portfolio(TEST_WALLET.to_string(), TEST_CHAIN_ID.to_string())
595            .await;
596        println!("Wallet portfolio result: {:?}", result);
597        assert!(result.is_ok());
598    }
599
600    #[tokio::test]
601    async fn test_get_wallet_portfolio_multichain() {
602        let client = setup_client();
603        let result = client
604            .get_wallet_portfolio_multichain(TEST_WALLET.to_string())
605            .await;
606        println!("Multichain portfolio result: {:?}", result);
607        assert!(result.is_ok());
608    }
609
610    // #[tokio::test]
611    // async fn test_get_wallet_token_balance() {
612    //     let client = setup_client();
613    //     let result = client
614    //         .get_wallet_token_balance(
615    //             TEST_WALLET.to_string(),
616    //             SOL_ADDRESS.to_string(),
617    //             TEST_CHAIN_ID.to_string(),
618    //         )
619    //         .await;
620    //     println!("Token balance result: {:?}", result);
621    //     assert!(result.is_ok());
622    // }
623
624    #[tokio::test]
625    async fn test_get_wallet_transaction_history() {
626        let client = setup_client();
627        let result = client
628            .get_wallet_transaction_history(
629                TEST_WALLET.to_string(),
630                TEST_CHAIN_ID.to_string(),
631                Some(10),
632                Some(0),
633            )
634            .await;
635        println!("Transaction history result: {:?}", result);
636        assert!(result.is_ok());
637    }
638
639    #[tokio::test]
640    async fn test_get_wallet_transaction_history_multichain() {
641        let client = setup_client();
642        let result = client
643            .get_wallet_transaction_history_multichain(TEST_WALLET.to_string(), Some(10), Some(0))
644            .await;
645        println!("Multichain transaction history result: {:?}", result);
646        assert!(result.is_ok());
647    }
648
649    #[tokio::test]
650    async fn test_simulate_transaction() {
651        let client = setup_client();
652        let result = client
653            .simulate_transaction(
654                TEST_CHAIN_ID.to_string(),
655                "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDBXsgXgYAAAAAAAA".to_string(),
656            )
657            .await;
658        println!("Transaction simulation result: {:?}", result);
659        assert!(result.is_ok());
660    }
661
662    #[tokio::test]
663    async fn test_error_handling() {
664        let client = BirdeyeClient::new("invalid-api-key".to_string());
665        let result = client.get_token_price(SOL_ADDRESS.to_string()).await;
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn test_format_resolution() {
671        assert_eq!(BirdeyeClient::format_resolution("1".to_string()), "1m");
672        assert_eq!(BirdeyeClient::format_resolution("15".to_string()), "15m");
673        assert_eq!(BirdeyeClient::format_resolution("1D".to_string()), "1D");
674        assert_eq!(BirdeyeClient::format_resolution("1W".to_string()), "1W");
675    }
676}