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