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#![deny(missing_docs)]
34
35#[cfg(not(any(feature = "native-tls", feature = "rustls-tls")))]
36compile_error!(
37    "noesis-api requires a TLS backend. Enable the default `native-tls` feature \
38     or opt into `rustls-tls`: `noesis-api = { version = \"0.3\", default-features = false, features = [\"rustls-tls\"] }`"
39);
40
41use serde_json::Value;
42use thiserror::Error;
43
44const DEFAULT_BASE_URL: &str = "https://noesisapi.dev";
45
46/// Errors returned by the Noesis SDK.
47#[derive(Debug, Error)]
48pub enum Error {
49    /// Transport-level HTTP error (DNS, TLS, connection reset, etc.).
50    #[error("HTTP error: {0}")]
51    Http(#[from] reqwest::Error),
52    /// HTTP 401 — missing or invalid API key.
53    #[error("Noesis 401 Unauthorized: {message}")]
54    Unauthorized {
55        /// Server-provided message (from the JSON body's `error` field).
56        message: String,
57    },
58    /// HTTP 404 — unknown address, token, or route.
59    #[error("Noesis 404 Not Found: {message}")]
60    NotFound {
61        /// Server-provided message (from the JSON body's `error` field).
62        message: String,
63    },
64    /// HTTP 429 — rate limit exceeded. Respect `retry_after_seconds`
65    /// before retrying. Falls back to the `Retry-After` response header
66    /// when the body omits the field.
67    #[error("Noesis 429 rate limited ({limit:?}); retry in {retry_after_seconds:?}s")]
68    RateLimit {
69        /// Seconds to wait before retrying, if known.
70        retry_after_seconds: Option<u64>,
71        /// Human-readable limit string, e.g. `"1 request/5 seconds"`.
72        limit: Option<String>,
73        /// Weight class of the throttled endpoint: `Light`, `Heavy`,
74        /// or `VeryHeavy`.
75        limit_type: Option<String>,
76        /// Whether the request was authenticated as a web user (different
77        /// rate-limit bucket).
78        signed_in: Option<bool>,
79        /// Parsed JSON body from the server, if any.
80        details: Option<Value>,
81    },
82    /// Any other non-2xx status the server returned.
83    #[error("Noesis API error {status}: {message}")]
84    Api {
85        /// HTTP status code.
86        status: u16,
87        /// Short message — the server's `error` field when available.
88        message: String,
89        /// Parsed JSON body from the server, if any.
90        details: Option<Value>,
91    },
92    /// JSON serialisation/deserialisation error.
93    #[error("JSON error: {0}")]
94    Json(#[from] serde_json::Error),
95}
96
97/// Convenience `Result` alias with the crate error type.
98pub type Result<T> = std::result::Result<T, Error>;
99
100/// Chain identifier. Noesis supports Solana and Base.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Chain {
103    /// Solana.
104    Sol,
105    /// Base (Coinbase L2).
106    Base,
107}
108
109impl Chain {
110    fn as_str(self) -> &'static str {
111        match self {
112            Chain::Sol => "sol",
113            Chain::Base => "base",
114        }
115    }
116}
117
118impl Default for Chain {
119    fn default() -> Self { Chain::Sol }
120}
121
122/// Transaction type filter for [`Noesis::wallet_history`].
123#[allow(missing_docs)]
124#[derive(Debug, Clone, Copy)]
125pub enum TxType {
126    Swap, Transfer, NftSale, NftListing, CompressedNftMint, TokenMint, Unknown,
127}
128
129impl TxType {
130    fn as_str(self) -> &'static str {
131        match self {
132            TxType::Swap => "SWAP",
133            TxType::Transfer => "TRANSFER",
134            TxType::NftSale => "NFT_SALE",
135            TxType::NftListing => "NFT_LISTING",
136            TxType::CompressedNftMint => "COMPRESSED_NFT_MINT",
137            TxType::TokenMint => "TOKEN_MINT",
138            TxType::Unknown => "UNKNOWN",
139        }
140    }
141}
142
143/// Source-protocol filter for [`Noesis::wallet_history`].
144#[allow(missing_docs)]
145#[derive(Debug, Clone, Copy)]
146pub enum TxSource {
147    Jupiter, Raydium, Orca, Meteora, PumpFun, SystemProgram, TokenProgram,
148}
149
150impl TxSource {
151    fn as_str(self) -> &'static str {
152        match self {
153            TxSource::Jupiter => "JUPITER",
154            TxSource::Raydium => "RAYDIUM",
155            TxSource::Orca => "ORCA",
156            TxSource::Meteora => "METEORA",
157            TxSource::PumpFun => "PUMP_FUN",
158            TxSource::SystemProgram => "SYSTEM_PROGRAM",
159            TxSource::TokenProgram => "TOKEN_PROGRAM",
160        }
161    }
162}
163
164/// Optional filters for [`Noesis::wallet_history`].
165#[derive(Debug, Default, Clone)]
166pub struct HistoryOptions {
167    /// Chain override. Defaults to Solana.
168    pub chain: Option<Chain>,
169    /// Number of transactions to return (1..=100, default 20).
170    pub limit: Option<u32>,
171    /// Filter by transaction type.
172    pub ty: Option<TxType>,
173    /// Filter by source protocol.
174    pub source: Option<TxSource>,
175    /// Paginate: only transactions before this signature.
176    pub before: Option<String>,
177}
178
179/// Optional filters for [`Noesis::token_holders`].
180#[derive(Debug, Default, Clone)]
181pub struct HoldersOptions {
182    /// Chain override. Defaults to Solana.
183    pub chain: Option<Chain>,
184    /// Number of holders to return (1..=1000, default 100).
185    pub limit: Option<u32>,
186    /// Pagination cursor from a previous response.
187    pub cursor: Option<String>,
188}
189
190/// Optional filters for [`Noesis::wallet_connections`].
191#[derive(Debug, Default, Clone)]
192pub struct ConnectionsOptions {
193    /// Minimum SOL threshold for a counterparty to be returned (default 0.1).
194    pub min_sol: Option<f64>,
195    /// Maximum pages of transaction history to scan (1..=20, default 20).
196    pub max_pages: Option<u32>,
197}
198
199/// Noesis API client.
200///
201/// Cheap to clone — shares the underlying [`reqwest::Client`] connection pool.
202#[derive(Clone)]
203pub struct Noesis {
204    http: reqwest::Client,
205    base_url: String,
206    api_key: String,
207}
208
209impl Noesis {
210    /// Create a client with the default base URL (`https://noesisapi.dev`).
211    pub fn new(api_key: impl Into<String>) -> Self {
212        Self::with_base_url(api_key, DEFAULT_BASE_URL)
213    }
214
215    /// Create a client with a custom base URL — useful for staging or
216    /// a self-hosted deployment.
217    pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
218        Self {
219            http: reqwest::Client::new(),
220            base_url: base_url.into().trim_end_matches('/').to_string(),
221            api_key: api_key.into(),
222        }
223    }
224
225    async fn get(&self, path: &str, query: &[(&str, String)]) -> Result<Value> {
226        let url = format!("{}/api/v1{}", self.base_url, path);
227        let res = self.http.get(&url)
228            .header("X-API-Key", &self.api_key)
229            .query(query)
230            .send()
231            .await?;
232        Self::handle(res).await
233    }
234
235    async fn post(&self, path: &str, body: &Value) -> Result<Value> {
236        let url = format!("{}/api/v1{}", self.base_url, path);
237        let res = self.http.post(&url)
238            .header("X-API-Key", &self.api_key)
239            .json(body)
240            .send()
241            .await?;
242        Self::handle(res).await
243    }
244
245    async fn handle(res: reqwest::Response) -> Result<Value> {
246        let status = res.status();
247        if status.is_success() {
248            return Ok(res.json().await?);
249        }
250
251        // Grab Retry-After BEFORE consuming the body.
252        let retry_hdr: Option<u64> = res.headers()
253            .get(reqwest::header::RETRY_AFTER)
254            .and_then(|v| v.to_str().ok())
255            .and_then(|s| s.parse::<u64>().ok());
256
257        let details = res.json::<Value>().await.ok();
258        let body_msg = details.as_ref()
259            .and_then(|v| v.get("error"))
260            .and_then(|v| v.as_str())
261            .map(str::to_string);
262
263        match status.as_u16() {
264            401 => Err(Error::Unauthorized {
265                message: body_msg.unwrap_or_else(|| "unauthorized".into()),
266            }),
267            404 => Err(Error::NotFound {
268                message: body_msg.unwrap_or_else(|| "not found".into()),
269            }),
270            429 => {
271                let body = details.as_ref();
272                let retry_body = body
273                    .and_then(|v| v.get("retry_after_seconds"))
274                    .and_then(|v| v.as_u64());
275                let limit = body
276                    .and_then(|v| v.get("limit"))
277                    .and_then(|v| v.as_str())
278                    .map(str::to_string);
279                let limit_type = body
280                    .and_then(|v| v.get("type"))
281                    .and_then(|v| v.as_str())
282                    .map(str::to_string);
283                let signed_in = body
284                    .and_then(|v| v.get("signed_in"))
285                    .and_then(|v| v.as_bool());
286                Err(Error::RateLimit {
287                    retry_after_seconds: retry_body.or(retry_hdr),
288                    limit,
289                    limit_type,
290                    signed_in,
291                    details,
292                })
293            }
294            code => Err(Error::Api {
295                status: code,
296                message: body_msg.unwrap_or_else(|| format!("Noesis API error {code}")),
297                details,
298            }),
299        }
300    }
301
302    // ─── Token ──────────────────────────────────────────────────────
303
304    /// Flat token metadata + price + pools. **Light** rate limit.
305    pub async fn token_preview(&self, mint: &str) -> Result<Value> {
306        self.token_preview_on(mint, Chain::Sol).await
307    }
308
309    /// Like [`token_preview`](Self::token_preview), explicit chain.
310    pub async fn token_preview_on(&self, mint: &str, chain: Chain) -> Result<Value> {
311        self.get(&format!("/token/{mint}/preview"), &[("chain", chain.as_str().into())]).await
312    }
313
314    /// Full scan: top holders, bundles, fresh wallets, dev profile. **Heavy** rate limit.
315    pub async fn token_scan(&self, mint: &str) -> Result<Value> {
316        self.token_scan_on(mint, Chain::Sol).await
317    }
318
319    /// Like [`token_scan`](Self::token_scan), explicit chain.
320    pub async fn token_scan_on(&self, mint: &str, chain: Chain) -> Result<Value> {
321        self.get(&format!("/token/{mint}/scan"), &[("chain", chain.as_str().into())]).await
322    }
323
324    /// Detailed on-chain token metadata — authorities, supply, raw DAS asset. **Light** rate limit.
325    pub async fn token_info(&self, mint: &str, chain: Chain) -> Result<Value> {
326        self.get(&format!("/token/{mint}/info"), &[("chain", chain.as_str().into())]).await
327    }
328
329    /// Top 20 holders with labels and tags. **Heavy** rate limit.
330    pub async fn token_top_holders(&self, mint: &str) -> Result<Value> {
331        self.get(&format!("/token/{mint}/top-holders"), &[]).await
332    }
333
334    /// Paginated full holders list (up to 1000 per page). **Light** rate limit.
335    pub async fn token_holders(&self, mint: &str, opts: HoldersOptions) -> Result<Value> {
336        let mut q: Vec<(&str, String)> = vec![
337            ("chain", opts.chain.unwrap_or_default().as_str().into()),
338        ];
339        if let Some(limit) = opts.limit { q.push(("limit", limit.to_string())); }
340        if let Some(cursor) = opts.cursor { q.push(("cursor", cursor)); }
341        self.get(&format!("/token/{mint}/holders"), &q).await
342    }
343
344    /// Bundle (sybil buy) detection. **Heavy** rate limit.
345    pub async fn token_bundles(&self, mint: &str) -> Result<Value> {
346        self.get(&format!("/token/{mint}/bundles"), &[]).await
347    }
348
349    /// Fresh wallet detection — wallets with no prior on-chain activity. **Heavy** rate limit.
350    pub async fn token_fresh_wallets(&self, mint: &str) -> Result<Value> {
351        self.get(&format!("/token/{mint}/fresh-wallets"), &[]).await
352    }
353
354    /// Team/insider supply detection via funding-pattern clustering. **Heavy** rate limit.
355    pub async fn token_team_supply(&self, mint: &str, chain: Chain) -> Result<Value> {
356        self.get(&format!("/token/{mint}/team-supply"), &[("chain", chain.as_str().into())]).await
357    }
358
359    /// Holder entry prices, realized & unrealized PnL. **Heavy** rate limit.
360    pub async fn token_entry_price(&self, mint: &str, chain: Chain) -> Result<Value> {
361        self.get(&format!("/token/{mint}/entry-price"), &[("chain", chain.as_str().into())]).await
362    }
363
364    /// Token creator profile — wallet data, prior coins, funding source. **Heavy** rate limit.
365    pub async fn token_dev_profile(&self, mint: &str) -> Result<Value> {
366        self.get(&format!("/token/{mint}/dev-profile"), &[]).await
367    }
368
369    /// Most profitable traders, enriched with labels. **Heavy** rate limit.
370    pub async fn token_best_traders(&self, mint: &str) -> Result<Value> {
371        self.get(&format!("/token/{mint}/best-traders"), &[]).await
372    }
373
374    /// Buyers within `hours` after token creation. **Heavy** rate limit.
375    pub async fn token_early_buyers(&self, mint: &str, hours: f32) -> Result<Value> {
376        self.get(&format!("/token/{mint}/early-buyers"), &[("hours", hours.to_string())]).await
377    }
378
379    // ─── Wallet ─────────────────────────────────────────────────────
380
381    /// Full wallet profile — PnL, holdings, labels, funding. **Heavy** rate limit.
382    pub async fn wallet_profile(&self, addr: &str) -> Result<Value> {
383        self.get(&format!("/wallet/{addr}"), &[]).await
384    }
385
386    /// Parsed transaction history with optional filtering & pagination. **Light** rate limit.
387    pub async fn wallet_history(&self, addr: &str, opts: HistoryOptions) -> Result<Value> {
388        let mut q: Vec<(&str, String)> = vec![
389            ("chain", opts.chain.unwrap_or_default().as_str().into()),
390        ];
391        if let Some(limit) = opts.limit { q.push(("limit", limit.to_string())); }
392        if let Some(ty) = opts.ty { q.push(("type", ty.as_str().into())); }
393        if let Some(source) = opts.source { q.push(("source", source.as_str().into())); }
394        if let Some(before) = opts.before { q.push(("before", before)); }
395        self.get(&format!("/wallet/{addr}/history"), &q).await
396    }
397
398    /// SOL transfer connections (counterparties with net flow). **Heavy** rate limit.
399    pub async fn wallet_connections(&self, addr: &str, opts: ConnectionsOptions) -> Result<Value> {
400        let mut q: Vec<(&str, String)> = vec![];
401        if let Some(min_sol) = opts.min_sol { q.push(("min_sol", min_sol.to_string())); }
402        if let Some(max_pages) = opts.max_pages { q.push(("max_pages", max_pages.to_string())); }
403        self.get(&format!("/wallet/{addr}/connections"), &q).await
404    }
405
406    /// Batch identity lookup — labels/tags/KOL info for up to 100 wallets. **Light** rate limit.
407    pub async fn wallets_batch_identity(&self, addresses: &[String]) -> Result<Value> {
408        self.post("/wallets/batch-identity", &serde_json::json!({ "addresses": addresses })).await
409    }
410
411    /// Wallets holding all specified tokens. **Heavy** rate limit.
412    pub async fn cross_holders(&self, tokens: &[String]) -> Result<Value> {
413        self.post("/tokens/cross-holders", &serde_json::json!({ "tokens": tokens })).await
414    }
415
416    /// Wallets that traded all specified tokens. **Heavy** rate limit.
417    pub async fn cross_traders(&self, tokens: &[String]) -> Result<Value> {
418        self.post("/tokens/cross-traders", &serde_json::json!({ "tokens": tokens })).await
419    }
420
421    // ─── Chain / On-Chain Data ──────────────────────────────────────
422
423    /// Current slot, block height, epoch info. **Light** rate limit.
424    pub async fn chain_status(&self) -> Result<Value> {
425        self.get("/chain/status", &[]).await
426    }
427
428    /// Account data (owner, lamports, data) for a single address. **Light** rate limit.
429    pub async fn account(&self, addr: &str) -> Result<Value> {
430        self.get(&format!("/account/{addr}"), &[]).await
431    }
432
433    /// Batch account data for up to 100 addresses. **Light** rate limit.
434    pub async fn accounts_batch(&self, addresses: &[String]) -> Result<Value> {
435        self.post("/accounts/batch", &serde_json::json!({ "addresses": addresses })).await
436    }
437
438    /// Parse up to 100 transaction signatures into human-readable events. **Light** rate limit.
439    pub async fn parse_transactions(&self, signatures: &[String]) -> Result<Value> {
440        self.post("/transactions/parse", &serde_json::json!({ "transactions": signatures })).await
441    }
442}