Skip to main content

noesis_api/
lib.rs

1//! Official Rust SDK for the [Noesis](https://noesisapi.dev) on-chain
2//! intelligence API — Solana token & wallet analytics.
3//!
4//! All endpoints return [`serde_json::Value`]. Deserialize into your own
5//! domain types as needed — the SDK is deliberately schema-agnostic so new
6//! response fields don't break compilation.
7//!
8//! Get an API key at [noesisapi.dev/keys](https://noesisapi.dev/keys).
9//!
10//! # Example
11//!
12//! ```no_run
13//! use noesis_api::Noesis;
14//!
15//! # async fn demo() -> Result<(), noesis_api::Error> {
16//! let client = Noesis::new("se_...");
17//!
18//! let preview = client.token_preview("So11111111111111111111111111111111111111112").await?;
19//! println!("{preview:#}");
20//!
21//! let bundles = client.token_bundles("<MINT>").await?;
22//! println!("{bundles:#}");
23//! # Ok(()) }
24//! ```
25//!
26//! # Rate limits
27//!
28//! Endpoints are tagged **Light** (1 req/sec), **Heavy** (1 req / 5 sec),
29//! or **VeryHeavy** (1 req/min, internal only). The API returns HTTP 429
30//! when you exceed the limit; this surfaces as [`Error::RateLimit`] with
31//! a typed `retry_after_seconds` field.
32//!
33//! # Live streams
34//!
35//! Four SSE endpoints are exposed as `impl Stream<Item = Result<Value>>`:
36//! [`Noesis::stream_pumpfun_new_tokens`], [`Noesis::stream_pumpfun_migrations`],
37//! [`Noesis::stream_raydium_new_pools`], [`Noesis::stream_meteora_new_pools`].
38//! Consume with [`futures_util::StreamExt::next`].
39
40#![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/// Errors returned by the Noesis SDK.
57#[derive(Debug, Error)]
58pub enum Error {
59    /// Transport-level HTTP error (DNS, TLS, connection reset, etc.).
60    #[error("HTTP error: {0}")]
61    Http(#[from] reqwest::Error),
62    /// HTTP 401 — missing or invalid API key.
63    #[error("Noesis 401 Unauthorized: {message}")]
64    Unauthorized {
65        /// Server-provided message (from the JSON body's `error` field).
66        message: String,
67    },
68    /// HTTP 404 — unknown address, token, or route.
69    #[error("Noesis 404 Not Found: {message}")]
70    NotFound {
71        /// Server-provided message (from the JSON body's `error` field).
72        message: String,
73    },
74    /// HTTP 429 — rate limit exceeded. Respect `retry_after_seconds`
75    /// before retrying. Falls back to the `Retry-After` response header
76    /// when the body omits the field.
77    #[error("Noesis 429 rate limited ({limit:?}); retry in {retry_after_seconds:?}s")]
78    RateLimit {
79        /// Seconds to wait before retrying, if known.
80        retry_after_seconds: Option<u64>,
81        /// Human-readable limit string, e.g. `"1 request/5 seconds"`.
82        limit: Option<String>,
83        /// Weight class of the throttled endpoint: `Light`, `Heavy`,
84        /// or `VeryHeavy`.
85        limit_type: Option<String>,
86        /// Whether the request was authenticated as a web user (different
87        /// rate-limit bucket).
88        signed_in: Option<bool>,
89        /// Parsed JSON body from the server, if any.
90        details: Option<Value>,
91    },
92    /// Any other non-2xx status the server returned.
93    #[error("Noesis API error {status}: {message}")]
94    Api {
95        /// HTTP status code.
96        status: u16,
97        /// Short message — the server's `error` field when available.
98        message: String,
99        /// Parsed JSON body from the server, if any.
100        details: Option<Value>,
101    },
102    /// JSON serialisation/deserialisation error.
103    #[error("JSON error: {0}")]
104    Json(#[from] serde_json::Error),
105}
106
107/// Convenience `Result` alias with the crate error type.
108pub type Result<T> = std::result::Result<T, Error>;
109
110// Maps a non-success `reqwest::Response` into a typed `Error`.
111// Reads `Retry-After` before consuming the body so it's still available
112// for 429 responses that also set the header.
113async 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/// Chain identifier. Noesis supports Solana and Base.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Chain {
169    /// Solana.
170    Sol,
171    /// Base (Coinbase L2).
172    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/// Transaction type filter for [`Noesis::wallet_history`].
189#[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/// Source-protocol filter for [`Noesis::wallet_history`].
210#[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/// Optional filters for [`Noesis::wallet_history`].
231#[derive(Debug, Default, Clone)]
232pub struct HistoryOptions {
233    /// Chain override. Defaults to Solana.
234    pub chain: Option<Chain>,
235    /// Number of transactions to return (1..=100, default 20).
236    pub limit: Option<u32>,
237    /// Filter by transaction type.
238    pub ty: Option<TxType>,
239    /// Filter by source protocol.
240    pub source: Option<TxSource>,
241    /// Paginate: only transactions before this signature.
242    pub before: Option<String>,
243}
244
245/// Optional filters for [`Noesis::token_holders`].
246#[derive(Debug, Default, Clone)]
247pub struct HoldersOptions {
248    /// Chain override. Defaults to Solana.
249    pub chain: Option<Chain>,
250    /// Number of holders to return (1..=1000, default 100).
251    pub limit: Option<u32>,
252    /// Pagination cursor from a previous response.
253    pub cursor: Option<String>,
254}
255
256/// Optional filters for [`Noesis::wallet_connections`].
257#[derive(Debug, Default, Clone)]
258pub struct ConnectionsOptions {
259    /// Minimum SOL threshold for a counterparty to be returned (default 0.1).
260    pub min_sol: Option<f64>,
261    /// Maximum pages of transaction history to scan (1..=20, default 20).
262    pub max_pages: Option<u32>,
263}
264
265/// Noesis API client.
266///
267/// Cheap to clone — shares the underlying [`reqwest::Client`] connection pool.
268#[derive(Clone)]
269pub struct Noesis {
270    http: reqwest::Client,
271    base_url: String,
272    api_key: String,
273}
274
275impl Noesis {
276    /// Create a client with the default base URL (`https://noesisapi.dev`).
277    pub fn new(api_key: impl Into<String>) -> Self {
278        Self::with_base_url(api_key, DEFAULT_BASE_URL)
279    }
280
281    /// Create a client with a custom base URL — useful for staging or
282    /// a self-hosted deployment.
283    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    // ─── Token ──────────────────────────────────────────────────────
319
320    /// Flat token metadata + price + pools. **Light** rate limit.
321    pub async fn token_preview(&self, mint: &str) -> Result<Value> {
322        self.token_preview_on(mint, Chain::Sol).await
323    }
324
325    /// Like [`token_preview`](Self::token_preview), explicit chain.
326    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    /// Full scan: top holders, bundles, fresh wallets, dev profile. **Heavy** rate limit.
331    pub async fn token_scan(&self, mint: &str) -> Result<Value> {
332        self.token_scan_on(mint, Chain::Sol).await
333    }
334
335    /// Like [`token_scan`](Self::token_scan), explicit chain.
336    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    /// Detailed on-chain token metadata — authorities, supply, raw DAS asset. **Light** rate limit.
341    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    /// Top 20 holders with labels and tags. **Heavy** rate limit.
346    pub async fn token_top_holders(&self, mint: &str) -> Result<Value> {
347        self.get(&format!("/token/{mint}/top-holders"), &[]).await
348    }
349
350    /// Paginated full holders list (up to 1000 per page). **Light** rate limit.
351    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    /// Bundle (sybil buy) detection. **Heavy** rate limit.
361    pub async fn token_bundles(&self, mint: &str) -> Result<Value> {
362        self.get(&format!("/token/{mint}/bundles"), &[]).await
363    }
364
365    /// Fresh wallet detection — wallets with no prior on-chain activity. **Heavy** rate limit.
366    pub async fn token_fresh_wallets(&self, mint: &str) -> Result<Value> {
367        self.get(&format!("/token/{mint}/fresh-wallets"), &[]).await
368    }
369
370    /// Team/insider supply detection via funding-pattern clustering. **Heavy** rate limit.
371    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    /// Holder entry prices, realized & unrealized PnL. **Heavy** rate limit.
376    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    /// Token creator profile — wallet data, prior coins, funding source. **Heavy** rate limit.
381    pub async fn token_dev_profile(&self, mint: &str) -> Result<Value> {
382        self.get(&format!("/token/{mint}/dev-profile"), &[]).await
383    }
384
385    /// Most profitable traders, enriched with labels. **Heavy** rate limit.
386    pub async fn token_best_traders(&self, mint: &str) -> Result<Value> {
387        self.get(&format!("/token/{mint}/best-traders"), &[]).await
388    }
389
390    /// Buyers within `hours` after token creation. **Heavy** rate limit.
391    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    // ─── Wallet ─────────────────────────────────────────────────────
396
397    /// Full wallet profile — PnL, holdings, labels, funding. **Heavy** rate limit.
398    pub async fn wallet_profile(&self, addr: &str) -> Result<Value> {
399        self.get(&format!("/wallet/{addr}"), &[]).await
400    }
401
402    /// Parsed transaction history with optional filtering & pagination. **Light** rate limit.
403    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    /// SOL transfer connections (counterparties with net flow). **Heavy** rate limit.
415    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    /// Batch identity lookup — labels/tags/KOL info for up to 100 wallets. **Light** rate limit.
423    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    /// Wallets holding all specified tokens. **Heavy** rate limit.
428    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    /// Wallets that traded all specified tokens. **Heavy** rate limit.
433    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    // ─── Chain / On-Chain Data ──────────────────────────────────────
438
439    /// Current slot, block height, epoch info. **Light** rate limit.
440    pub async fn chain_status(&self) -> Result<Value> {
441        self.get("/chain/status", &[]).await
442    }
443
444    /// Account data (owner, lamports, data) for a single address. **Light** rate limit.
445    pub async fn account(&self, addr: &str) -> Result<Value> {
446        self.get(&format!("/account/{addr}"), &[]).await
447    }
448
449    /// Batch account data for up to 100 addresses. **Light** rate limit.
450    pub async fn accounts_batch(&self, addresses: &[String]) -> Result<Value> {
451        self.post("/accounts/batch", &serde_json::json!({ "addresses": addresses })).await
452    }
453
454    /// Parse up to 100 transaction signatures into human-readable events. **Light** rate limit.
455    pub async fn parse_transactions(&self, signatures: &[String]) -> Result<Value> {
456        self.post("/transactions/parse", &serde_json::json!({ "transactions": signatures })).await
457    }
458
459    // ─── SSE Streams ────────────────────────────────────────────────
460
461    /// Live stream of new PumpFun token creations.
462    ///
463    /// Yields one [`Value`] per SSE event. Consume with
464    /// [`futures_util::StreamExt::next`] or `tokio_stream::StreamExt`. Auth
465    /// errors and transport failures surface as [`Err`] items — the stream
466    /// ends after the first such error.
467    pub fn stream_pumpfun_new_tokens(&self) -> impl Stream<Item = Result<Value>> + 'static {
468        self.sse_stream("/stream/pumpfun/new-tokens")
469    }
470
471    /// Live stream of PumpFun bonding-curve migrations (graduations).
472    pub fn stream_pumpfun_migrations(&self) -> impl Stream<Item = Result<Value>> + 'static {
473        self.sse_stream("/stream/pumpfun/migrations")
474    }
475
476    /// Live stream of new Raydium pools.
477    pub fn stream_raydium_new_pools(&self) -> impl Stream<Item = Result<Value>> + 'static {
478        self.sse_stream("/stream/raydium/new-pools")
479    }
480
481    /// Live stream of new Meteora pools.
482    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}