1#![deny(missing_docs)]
41
42#[cfg(not(any(feature = "native-tls", feature = "rustls-tls")))]
43compile_error!(
44 "noesis-api requires a TLS backend. Enable the default `native-tls` feature \
45 or opt into `rustls-tls`: `noesis-api = { version = \"0.3\", default-features = false, features = [\"rustls-tls\"] }`"
46);
47
48use async_stream::stream;
49use futures_core::Stream;
50use futures_util::StreamExt;
51use serde_json::Value;
52use thiserror::Error;
53
54const DEFAULT_BASE_URL: &str = "https://noesisapi.dev";
55
56#[derive(Debug, Error)]
58pub enum Error {
59 #[error("HTTP error: {0}")]
61 Http(#[from] reqwest::Error),
62 #[error("Noesis 401 Unauthorized: {message}")]
64 Unauthorized {
65 message: String,
67 },
68 #[error("Noesis 404 Not Found: {message}")]
70 NotFound {
71 message: String,
73 },
74 #[error("Noesis 429 rate limited ({limit:?}); retry in {retry_after_seconds:?}s")]
78 RateLimit {
79 retry_after_seconds: Option<u64>,
81 limit: Option<String>,
83 limit_type: Option<String>,
86 signed_in: Option<bool>,
89 details: Option<Value>,
91 },
92 #[error("Noesis API error {status}: {message}")]
94 Api {
95 status: u16,
97 message: String,
99 details: Option<Value>,
101 },
102 #[error("JSON error: {0}")]
104 Json(#[from] serde_json::Error),
105}
106
107pub type Result<T> = std::result::Result<T, Error>;
109
110async fn error_from_response(res: reqwest::Response) -> Error {
114 let status = res.status();
115
116 let retry_hdr: Option<u64> = res.headers()
117 .get(reqwest::header::RETRY_AFTER)
118 .and_then(|v| v.to_str().ok())
119 .and_then(|s| s.parse::<u64>().ok());
120
121 let details = res.json::<Value>().await.ok();
122 let body_msg = details.as_ref()
123 .and_then(|v| v.get("error"))
124 .and_then(|v| v.as_str())
125 .map(str::to_string);
126
127 match status.as_u16() {
128 401 => Error::Unauthorized {
129 message: body_msg.unwrap_or_else(|| "unauthorized".into()),
130 },
131 404 => Error::NotFound {
132 message: body_msg.unwrap_or_else(|| "not found".into()),
133 },
134 429 => {
135 let body = details.as_ref();
136 let retry_body = body
137 .and_then(|v| v.get("retry_after_seconds"))
138 .and_then(|v| v.as_u64());
139 let limit = body
140 .and_then(|v| v.get("limit"))
141 .and_then(|v| v.as_str())
142 .map(str::to_string);
143 let limit_type = body
144 .and_then(|v| v.get("type"))
145 .and_then(|v| v.as_str())
146 .map(str::to_string);
147 let signed_in = body
148 .and_then(|v| v.get("signed_in"))
149 .and_then(|v| v.as_bool());
150 Error::RateLimit {
151 retry_after_seconds: retry_body.or(retry_hdr),
152 limit,
153 limit_type,
154 signed_in,
155 details,
156 }
157 }
158 code => Error::Api {
159 status: code,
160 message: body_msg.unwrap_or_else(|| format!("Noesis API error {code}")),
161 details,
162 },
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Chain {
169 Sol,
171 Base,
173}
174
175impl Chain {
176 fn as_str(self) -> &'static str {
177 match self {
178 Chain::Sol => "sol",
179 Chain::Base => "base",
180 }
181 }
182}
183
184impl Default for Chain {
185 fn default() -> Self { Chain::Sol }
186}
187
188#[allow(missing_docs)]
190#[derive(Debug, Clone, Copy)]
191pub enum TxType {
192 Swap, Transfer, NftSale, NftListing, CompressedNftMint, TokenMint, Unknown,
193}
194
195impl TxType {
196 fn as_str(self) -> &'static str {
197 match self {
198 TxType::Swap => "SWAP",
199 TxType::Transfer => "TRANSFER",
200 TxType::NftSale => "NFT_SALE",
201 TxType::NftListing => "NFT_LISTING",
202 TxType::CompressedNftMint => "COMPRESSED_NFT_MINT",
203 TxType::TokenMint => "TOKEN_MINT",
204 TxType::Unknown => "UNKNOWN",
205 }
206 }
207}
208
209#[allow(missing_docs)]
211#[derive(Debug, Clone, Copy)]
212pub enum TxSource {
213 Jupiter, Raydium, Orca, Meteora, PumpFun, SystemProgram, TokenProgram,
214}
215
216impl TxSource {
217 fn as_str(self) -> &'static str {
218 match self {
219 TxSource::Jupiter => "JUPITER",
220 TxSource::Raydium => "RAYDIUM",
221 TxSource::Orca => "ORCA",
222 TxSource::Meteora => "METEORA",
223 TxSource::PumpFun => "PUMP_FUN",
224 TxSource::SystemProgram => "SYSTEM_PROGRAM",
225 TxSource::TokenProgram => "TOKEN_PROGRAM",
226 }
227 }
228}
229
230#[derive(Debug, Default, Clone)]
232pub struct HistoryOptions {
233 pub chain: Option<Chain>,
235 pub limit: Option<u32>,
237 pub ty: Option<TxType>,
239 pub source: Option<TxSource>,
241 pub before: Option<String>,
243}
244
245#[derive(Debug, Default, Clone)]
247pub struct HoldersOptions {
248 pub chain: Option<Chain>,
250 pub limit: Option<u32>,
252 pub cursor: Option<String>,
254}
255
256#[derive(Debug, Default, Clone)]
258pub struct ConnectionsOptions {
259 pub min_sol: Option<f64>,
261 pub max_pages: Option<u32>,
263}
264
265#[derive(Clone)]
269pub struct Noesis {
270 http: reqwest::Client,
271 base_url: String,
272 api_key: String,
273}
274
275impl Noesis {
276 pub fn new(api_key: impl Into<String>) -> Self {
278 Self::with_base_url(api_key, DEFAULT_BASE_URL)
279 }
280
281 pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
284 Self {
285 http: reqwest::Client::new(),
286 base_url: base_url.into().trim_end_matches('/').to_string(),
287 api_key: api_key.into(),
288 }
289 }
290
291 async fn get(&self, path: &str, query: &[(&str, String)]) -> Result<Value> {
292 let url = format!("{}/api/v1{}", self.base_url, path);
293 let res = self.http.get(&url)
294 .header("X-API-Key", &self.api_key)
295 .query(query)
296 .send()
297 .await?;
298 Self::handle(res).await
299 }
300
301 async fn post(&self, path: &str, body: &Value) -> Result<Value> {
302 let url = format!("{}/api/v1{}", self.base_url, path);
303 let res = self.http.post(&url)
304 .header("X-API-Key", &self.api_key)
305 .json(body)
306 .send()
307 .await?;
308 Self::handle(res).await
309 }
310
311 async fn handle(res: reqwest::Response) -> Result<Value> {
312 if res.status().is_success() {
313 return Ok(res.json().await?);
314 }
315 Err(error_from_response(res).await)
316 }
317
318 pub async fn token_preview(&self, mint: &str) -> Result<Value> {
322 self.token_preview_on(mint, Chain::Sol).await
323 }
324
325 pub async fn token_preview_on(&self, mint: &str, chain: Chain) -> Result<Value> {
327 self.get(&format!("/token/{mint}/preview"), &[("chain", chain.as_str().into())]).await
328 }
329
330 pub async fn token_scan(&self, mint: &str) -> Result<Value> {
332 self.token_scan_on(mint, Chain::Sol).await
333 }
334
335 pub async fn token_scan_on(&self, mint: &str, chain: Chain) -> Result<Value> {
337 self.get(&format!("/token/{mint}/scan"), &[("chain", chain.as_str().into())]).await
338 }
339
340 pub async fn token_info(&self, mint: &str, chain: Chain) -> Result<Value> {
342 self.get(&format!("/token/{mint}/info"), &[("chain", chain.as_str().into())]).await
343 }
344
345 pub async fn token_top_holders(&self, mint: &str) -> Result<Value> {
347 self.get(&format!("/token/{mint}/top-holders"), &[]).await
348 }
349
350 pub async fn token_holders(&self, mint: &str, opts: HoldersOptions) -> Result<Value> {
352 let mut q: Vec<(&str, String)> = vec![
353 ("chain", opts.chain.unwrap_or_default().as_str().into()),
354 ];
355 if let Some(limit) = opts.limit { q.push(("limit", limit.to_string())); }
356 if let Some(cursor) = opts.cursor { q.push(("cursor", cursor)); }
357 self.get(&format!("/token/{mint}/holders"), &q).await
358 }
359
360 pub async fn token_bundles(&self, mint: &str) -> Result<Value> {
362 self.get(&format!("/token/{mint}/bundles"), &[]).await
363 }
364
365 pub async fn token_fresh_wallets(&self, mint: &str) -> Result<Value> {
367 self.get(&format!("/token/{mint}/fresh-wallets"), &[]).await
368 }
369
370 pub async fn token_team_supply(&self, mint: &str, chain: Chain) -> Result<Value> {
372 self.get(&format!("/token/{mint}/team-supply"), &[("chain", chain.as_str().into())]).await
373 }
374
375 pub async fn token_entry_price(&self, mint: &str, chain: Chain) -> Result<Value> {
377 self.get(&format!("/token/{mint}/entry-price"), &[("chain", chain.as_str().into())]).await
378 }
379
380 pub async fn token_dev_profile(&self, mint: &str) -> Result<Value> {
382 self.get(&format!("/token/{mint}/dev-profile"), &[]).await
383 }
384
385 pub async fn token_best_traders(&self, mint: &str) -> Result<Value> {
387 self.get(&format!("/token/{mint}/best-traders"), &[]).await
388 }
389
390 pub async fn token_early_buyers(&self, mint: &str, hours: f32) -> Result<Value> {
392 self.get(&format!("/token/{mint}/early-buyers"), &[("hours", hours.to_string())]).await
393 }
394
395 pub async fn wallet_profile(&self, addr: &str) -> Result<Value> {
399 self.get(&format!("/wallet/{addr}"), &[]).await
400 }
401
402 pub async fn wallet_history(&self, addr: &str, opts: HistoryOptions) -> Result<Value> {
404 let mut q: Vec<(&str, String)> = vec![
405 ("chain", opts.chain.unwrap_or_default().as_str().into()),
406 ];
407 if let Some(limit) = opts.limit { q.push(("limit", limit.to_string())); }
408 if let Some(ty) = opts.ty { q.push(("type", ty.as_str().into())); }
409 if let Some(source) = opts.source { q.push(("source", source.as_str().into())); }
410 if let Some(before) = opts.before { q.push(("before", before)); }
411 self.get(&format!("/wallet/{addr}/history"), &q).await
412 }
413
414 pub async fn wallet_connections(&self, addr: &str, opts: ConnectionsOptions) -> Result<Value> {
416 let mut q: Vec<(&str, String)> = vec![];
417 if let Some(min_sol) = opts.min_sol { q.push(("min_sol", min_sol.to_string())); }
418 if let Some(max_pages) = opts.max_pages { q.push(("max_pages", max_pages.to_string())); }
419 self.get(&format!("/wallet/{addr}/connections"), &q).await
420 }
421
422 pub async fn wallets_batch_identity(&self, addresses: &[String]) -> Result<Value> {
424 self.post("/wallets/batch-identity", &serde_json::json!({ "addresses": addresses })).await
425 }
426
427 pub async fn cross_holders(&self, tokens: &[String]) -> Result<Value> {
429 self.post("/tokens/cross-holders", &serde_json::json!({ "tokens": tokens })).await
430 }
431
432 pub async fn cross_traders(&self, tokens: &[String]) -> Result<Value> {
434 self.post("/tokens/cross-traders", &serde_json::json!({ "tokens": tokens })).await
435 }
436
437 pub async fn chain_status(&self) -> Result<Value> {
441 self.get("/chain/status", &[]).await
442 }
443
444 pub async fn account(&self, addr: &str) -> Result<Value> {
446 self.get(&format!("/account/{addr}"), &[]).await
447 }
448
449 pub async fn accounts_batch(&self, addresses: &[String]) -> Result<Value> {
451 self.post("/accounts/batch", &serde_json::json!({ "addresses": addresses })).await
452 }
453
454 pub async fn parse_transactions(&self, signatures: &[String]) -> Result<Value> {
456 self.post("/transactions/parse", &serde_json::json!({ "transactions": signatures })).await
457 }
458
459 pub fn stream_pumpfun_new_tokens(&self) -> impl Stream<Item = Result<Value>> + 'static {
468 self.sse_stream("/stream/pumpfun/new-tokens")
469 }
470
471 pub fn stream_pumpfun_migrations(&self) -> impl Stream<Item = Result<Value>> + 'static {
473 self.sse_stream("/stream/pumpfun/migrations")
474 }
475
476 pub fn stream_raydium_new_pools(&self) -> impl Stream<Item = Result<Value>> + 'static {
478 self.sse_stream("/stream/raydium/new-pools")
479 }
480
481 pub fn stream_meteora_new_pools(&self) -> impl Stream<Item = Result<Value>> + 'static {
483 self.sse_stream("/stream/meteora/new-pools")
484 }
485
486 fn sse_stream(&self, path: &str) -> impl Stream<Item = Result<Value>> + 'static {
487 let url = format!("{}/api/v1{}", self.base_url, path);
488 let http = self.http.clone();
489 let api_key = self.api_key.clone();
490
491 stream! {
492 let res = match http.get(&url)
493 .header("X-API-Key", &api_key)
494 .header("Accept", "text/event-stream")
495 .send()
496 .await
497 {
498 Ok(r) => r,
499 Err(e) => { yield Err(Error::Http(e)); return; }
500 };
501
502 if !res.status().is_success() {
503 yield Err(error_from_response(res).await);
504 return;
505 }
506
507 let mut bytes_stream = res.bytes_stream();
508 let mut buf: Vec<u8> = Vec::new();
509
510 while let Some(chunk) = bytes_stream.next().await {
511 let chunk = match chunk {
512 Ok(c) => c,
513 Err(e) => { yield Err(Error::Http(e)); return; }
514 };
515 buf.extend_from_slice(&chunk);
516
517 while let Some(idx) = buf.iter().position(|&b| b == b'\n') {
518 let line_bytes: Vec<u8> = buf.drain(..=idx).collect();
519 let Ok(line) = std::str::from_utf8(&line_bytes) else { continue };
520 let line = line.trim_end_matches(['\r', '\n']);
521 let Some(payload) = line.strip_prefix("data:") else { continue };
522 let payload = payload.trim();
523 if payload.is_empty() { continue; }
524 match serde_json::from_str::<Value>(payload) {
525 Ok(v) => yield Ok(v),
526 Err(_) => yield Ok(serde_json::json!({ "raw": payload })),
527 }
528 }
529 }
530 }
531 }
532}