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