1#![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#[derive(Debug, Error)]
48pub enum Error {
49 #[error("HTTP error: {0}")]
51 Http(#[from] reqwest::Error),
52 #[error("Noesis 401 Unauthorized: {message}")]
54 Unauthorized {
55 message: String,
57 },
58 #[error("Noesis 404 Not Found: {message}")]
60 NotFound {
61 message: String,
63 },
64 #[error("Noesis 429 rate limited ({limit:?}); retry in {retry_after_seconds:?}s")]
68 RateLimit {
69 retry_after_seconds: Option<u64>,
71 limit: Option<String>,
73 limit_type: Option<String>,
76 signed_in: Option<bool>,
79 details: Option<Value>,
81 },
82 #[error("Noesis API error {status}: {message}")]
84 Api {
85 status: u16,
87 message: String,
89 details: Option<Value>,
91 },
92 #[error("JSON error: {0}")]
94 Json(#[from] serde_json::Error),
95}
96
97pub type Result<T> = std::result::Result<T, Error>;
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Chain {
103 Sol,
105 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#[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#[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#[derive(Debug, Default, Clone)]
166pub struct HistoryOptions {
167 pub chain: Option<Chain>,
169 pub limit: Option<u32>,
171 pub ty: Option<TxType>,
173 pub source: Option<TxSource>,
175 pub before: Option<String>,
177}
178
179#[derive(Debug, Default, Clone)]
181pub struct HoldersOptions {
182 pub chain: Option<Chain>,
184 pub limit: Option<u32>,
186 pub cursor: Option<String>,
188}
189
190#[derive(Debug, Default, Clone)]
192pub struct ConnectionsOptions {
193 pub min_sol: Option<f64>,
195 pub max_pages: Option<u32>,
197}
198
199#[derive(Clone)]
203pub struct Noesis {
204 http: reqwest::Client,
205 base_url: String,
206 api_key: String,
207}
208
209impl Noesis {
210 pub fn new(api_key: impl Into<String>) -> Self {
212 Self::with_base_url(api_key, DEFAULT_BASE_URL)
213 }
214
215 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 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 pub async fn token_preview(&self, mint: &str) -> Result<Value> {
306 self.token_preview_on(mint, Chain::Sol).await
307 }
308
309 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 pub async fn token_scan(&self, mint: &str) -> Result<Value> {
316 self.token_scan_on(mint, Chain::Sol).await
317 }
318
319 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 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 pub async fn token_top_holders(&self, mint: &str) -> Result<Value> {
331 self.get(&format!("/token/{mint}/top-holders"), &[]).await
332 }
333
334 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 pub async fn token_bundles(&self, mint: &str) -> Result<Value> {
346 self.get(&format!("/token/{mint}/bundles"), &[]).await
347 }
348
349 pub async fn token_fresh_wallets(&self, mint: &str) -> Result<Value> {
351 self.get(&format!("/token/{mint}/fresh-wallets"), &[]).await
352 }
353
354 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 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 pub async fn token_dev_profile(&self, mint: &str) -> Result<Value> {
366 self.get(&format!("/token/{mint}/dev-profile"), &[]).await
367 }
368
369 pub async fn token_best_traders(&self, mint: &str) -> Result<Value> {
371 self.get(&format!("/token/{mint}/best-traders"), &[]).await
372 }
373
374 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 pub async fn wallet_profile(&self, addr: &str) -> Result<Value> {
383 self.get(&format!("/wallet/{addr}"), &[]).await
384 }
385
386 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 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 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 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 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 pub async fn chain_status(&self) -> Result<Value> {
425 self.get("/chain/status", &[]).await
426 }
427
428 pub async fn account(&self, addr: &str) -> Result<Value> {
430 self.get(&format!("/account/{addr}"), &[]).await
431 }
432
433 pub async fn accounts_batch(&self, addresses: &[String]) -> Result<Value> {
435 self.post("/accounts/batch", &serde_json::json!({ "addresses": addresses })).await
436 }
437
438 pub async fn parse_transactions(&self, signatures: &[String]) -> Result<Value> {
440 self.post("/transactions/parse", &serde_json::json!({ "transactions": signatures })).await
441 }
442}