1#![deny(missing_docs)]
34
35use serde_json::Value;
36use thiserror::Error;
37
38const DEFAULT_BASE_URL: &str = "https://noesisapi.dev";
39
40#[derive(Debug, Error)]
42pub enum Error {
43 #[error("HTTP error: {0}")]
45 Http(#[from] reqwest::Error),
46 #[error("Noesis 401 Unauthorized: {message}")]
48 Unauthorized {
49 message: String,
51 },
52 #[error("Noesis 404 Not Found: {message}")]
54 NotFound {
55 message: String,
57 },
58 #[error("Noesis 429 rate limited ({limit:?}); retry in {retry_after_seconds:?}s")]
62 RateLimit {
63 retry_after_seconds: Option<u64>,
65 limit: Option<String>,
67 limit_type: Option<String>,
70 signed_in: Option<bool>,
73 details: Option<Value>,
75 },
76 #[error("Noesis API error {status}: {message}")]
78 Api {
79 status: u16,
81 message: String,
83 details: Option<Value>,
85 },
86 #[error("JSON error: {0}")]
88 Json(#[from] serde_json::Error),
89}
90
91pub type Result<T> = std::result::Result<T, Error>;
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Chain {
97 Sol,
99 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#[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#[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#[derive(Debug, Default, Clone)]
160pub struct HistoryOptions {
161 pub chain: Option<Chain>,
163 pub limit: Option<u32>,
165 pub ty: Option<TxType>,
167 pub source: Option<TxSource>,
169 pub before: Option<String>,
171}
172
173#[derive(Debug, Default, Clone)]
175pub struct HoldersOptions {
176 pub chain: Option<Chain>,
178 pub limit: Option<u32>,
180 pub cursor: Option<String>,
182}
183
184#[derive(Debug, Default, Clone)]
186pub struct ConnectionsOptions {
187 pub min_sol: Option<f64>,
189 pub max_pages: Option<u32>,
191}
192
193#[derive(Clone)]
197pub struct Noesis {
198 http: reqwest::Client,
199 base_url: String,
200 api_key: String,
201}
202
203impl Noesis {
204 pub fn new(api_key: impl Into<String>) -> Self {
206 Self::with_base_url(api_key, DEFAULT_BASE_URL)
207 }
208
209 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 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 pub async fn token_preview(&self, mint: &str) -> Result<Value> {
300 self.token_preview_on(mint, Chain::Sol).await
301 }
302
303 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 pub async fn token_scan(&self, mint: &str) -> Result<Value> {
310 self.token_scan_on(mint, Chain::Sol).await
311 }
312
313 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 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 pub async fn token_top_holders(&self, mint: &str) -> Result<Value> {
325 self.get(&format!("/token/{mint}/top-holders"), &[]).await
326 }
327
328 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 pub async fn token_bundles(&self, mint: &str) -> Result<Value> {
340 self.get(&format!("/token/{mint}/bundles"), &[]).await
341 }
342
343 pub async fn token_fresh_wallets(&self, mint: &str) -> Result<Value> {
345 self.get(&format!("/token/{mint}/fresh-wallets"), &[]).await
346 }
347
348 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 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 pub async fn token_dev_profile(&self, mint: &str) -> Result<Value> {
360 self.get(&format!("/token/{mint}/dev-profile"), &[]).await
361 }
362
363 pub async fn token_best_traders(&self, mint: &str) -> Result<Value> {
365 self.get(&format!("/token/{mint}/best-traders"), &[]).await
366 }
367
368 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 pub async fn wallet_profile(&self, addr: &str) -> Result<Value> {
377 self.get(&format!("/wallet/{addr}"), &[]).await
378 }
379
380 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 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 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 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 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 pub async fn chain_status(&self) -> Result<Value> {
419 self.get("/chain/status", &[]).await
420 }
421
422 pub async fn account(&self, addr: &str) -> Result<Value> {
424 self.get(&format!("/account/{addr}"), &[]).await
425 }
426
427 pub async fn accounts_batch(&self, addresses: &[String]) -> Result<Value> {
429 self.post("/accounts/batch", &serde_json::json!({ "addresses": addresses })).await
430 }
431
432 pub async fn parse_transactions(&self, signatures: &[String]) -> Result<Value> {
434 self.post("/transactions/parse", &serde_json::json!({ "transactions": signatures })).await
435 }
436}