#![deny(missing_docs)]
#[cfg(not(any(feature = "native-tls", feature = "rustls-tls")))]
compile_error!(
"noesis-api requires a TLS backend. Enable the default `native-tls` feature \
or opt into `rustls-tls`: `noesis-api = { version = \"0.3\", default-features = false, features = [\"rustls-tls\"] }`"
);
use async_stream::stream;
use futures_core::Stream;
use futures_util::StreamExt;
use serde_json::Value;
use thiserror::Error;
const DEFAULT_BASE_URL: &str = "https://noesisapi.dev";
#[derive(Debug, Error)]
pub enum Error {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Noesis 401 Unauthorized: {message}")]
Unauthorized {
message: String,
},
#[error("Noesis 404 Not Found: {message}")]
NotFound {
message: String,
},
#[error("Noesis 429 rate limited ({limit:?}); retry in {retry_after_seconds:?}s")]
RateLimit {
retry_after_seconds: Option<u64>,
limit: Option<String>,
limit_type: Option<String>,
signed_in: Option<bool>,
details: Option<Value>,
},
#[error("Noesis API error {status}: {message}")]
Api {
status: u16,
message: String,
details: Option<Value>,
},
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
async fn error_from_response(res: reqwest::Response) -> Error {
let status = res.status();
let retry_hdr: Option<u64> = res.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let details = res.json::<Value>().await.ok();
let body_msg = details.as_ref()
.and_then(|v| v.get("error"))
.and_then(|v| v.as_str())
.map(str::to_string);
match status.as_u16() {
401 => Error::Unauthorized {
message: body_msg.unwrap_or_else(|| "unauthorized".into()),
},
404 => Error::NotFound {
message: body_msg.unwrap_or_else(|| "not found".into()),
},
429 => {
let body = details.as_ref();
let retry_body = body
.and_then(|v| v.get("retry_after_seconds"))
.and_then(|v| v.as_u64());
let limit = body
.and_then(|v| v.get("limit"))
.and_then(|v| v.as_str())
.map(str::to_string);
let limit_type = body
.and_then(|v| v.get("type"))
.and_then(|v| v.as_str())
.map(str::to_string);
let signed_in = body
.and_then(|v| v.get("signed_in"))
.and_then(|v| v.as_bool());
Error::RateLimit {
retry_after_seconds: retry_body.or(retry_hdr),
limit,
limit_type,
signed_in,
details,
}
}
code => Error::Api {
status: code,
message: body_msg.unwrap_or_else(|| format!("Noesis API error {code}")),
details,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chain {
Sol,
Base,
}
impl Chain {
fn as_str(self) -> &'static str {
match self {
Chain::Sol => "sol",
Chain::Base => "base",
}
}
}
impl Default for Chain {
fn default() -> Self { Chain::Sol }
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy)]
pub enum TxType {
Swap, Transfer, NftSale, NftListing, CompressedNftMint, TokenMint, Unknown,
}
impl TxType {
fn as_str(self) -> &'static str {
match self {
TxType::Swap => "SWAP",
TxType::Transfer => "TRANSFER",
TxType::NftSale => "NFT_SALE",
TxType::NftListing => "NFT_LISTING",
TxType::CompressedNftMint => "COMPRESSED_NFT_MINT",
TxType::TokenMint => "TOKEN_MINT",
TxType::Unknown => "UNKNOWN",
}
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy)]
pub enum TxSource {
Jupiter, Raydium, Orca, Meteora, PumpFun, SystemProgram, TokenProgram,
}
impl TxSource {
fn as_str(self) -> &'static str {
match self {
TxSource::Jupiter => "JUPITER",
TxSource::Raydium => "RAYDIUM",
TxSource::Orca => "ORCA",
TxSource::Meteora => "METEORA",
TxSource::PumpFun => "PUMP_FUN",
TxSource::SystemProgram => "SYSTEM_PROGRAM",
TxSource::TokenProgram => "TOKEN_PROGRAM",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct HistoryOptions {
pub chain: Option<Chain>,
pub limit: Option<u32>,
pub ty: Option<TxType>,
pub source: Option<TxSource>,
pub before: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct HoldersOptions {
pub chain: Option<Chain>,
pub limit: Option<u32>,
pub cursor: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct ConnectionsOptions {
pub min_sol: Option<f64>,
pub max_pages: Option<u32>,
}
#[derive(Clone)]
pub struct Noesis {
http: reqwest::Client,
base_url: String,
api_key: String,
}
impl Noesis {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
base_url: base_url.into().trim_end_matches('/').to_string(),
api_key: api_key.into(),
}
}
async fn get(&self, path: &str, query: &[(&str, String)]) -> Result<Value> {
let url = format!("{}/api/v1{}", self.base_url, path);
let res = self.http.get(&url)
.header("X-API-Key", &self.api_key)
.query(query)
.send()
.await?;
Self::handle(res).await
}
async fn post(&self, path: &str, body: &Value) -> Result<Value> {
let url = format!("{}/api/v1{}", self.base_url, path);
let res = self.http.post(&url)
.header("X-API-Key", &self.api_key)
.json(body)
.send()
.await?;
Self::handle(res).await
}
async fn handle(res: reqwest::Response) -> Result<Value> {
if res.status().is_success() {
return Ok(res.json().await?);
}
Err(error_from_response(res).await)
}
pub async fn token_preview(&self, mint: &str) -> Result<Value> {
self.token_preview_on(mint, Chain::Sol).await
}
pub async fn token_preview_on(&self, mint: &str, chain: Chain) -> Result<Value> {
self.get(&format!("/token/{mint}/preview"), &[("chain", chain.as_str().into())]).await
}
pub async fn token_scan(&self, mint: &str) -> Result<Value> {
self.token_scan_on(mint, Chain::Sol).await
}
pub async fn token_scan_on(&self, mint: &str, chain: Chain) -> Result<Value> {
self.get(&format!("/token/{mint}/scan"), &[("chain", chain.as_str().into())]).await
}
pub async fn token_info(&self, mint: &str, chain: Chain) -> Result<Value> {
self.get(&format!("/token/{mint}/info"), &[("chain", chain.as_str().into())]).await
}
pub async fn token_top_holders(&self, mint: &str) -> Result<Value> {
self.get(&format!("/token/{mint}/top-holders"), &[]).await
}
pub async fn token_holders(&self, mint: &str, opts: HoldersOptions) -> Result<Value> {
let mut q: Vec<(&str, String)> = vec![
("chain", opts.chain.unwrap_or_default().as_str().into()),
];
if let Some(limit) = opts.limit { q.push(("limit", limit.to_string())); }
if let Some(cursor) = opts.cursor { q.push(("cursor", cursor)); }
self.get(&format!("/token/{mint}/holders"), &q).await
}
pub async fn token_bundles(&self, mint: &str) -> Result<Value> {
self.get(&format!("/token/{mint}/bundles"), &[]).await
}
pub async fn token_fresh_wallets(&self, mint: &str) -> Result<Value> {
self.get(&format!("/token/{mint}/fresh-wallets"), &[]).await
}
pub async fn token_team_supply(&self, mint: &str, chain: Chain) -> Result<Value> {
self.get(&format!("/token/{mint}/team-supply"), &[("chain", chain.as_str().into())]).await
}
pub async fn token_entry_price(&self, mint: &str, chain: Chain) -> Result<Value> {
self.get(&format!("/token/{mint}/entry-price"), &[("chain", chain.as_str().into())]).await
}
pub async fn token_dev_profile(&self, mint: &str) -> Result<Value> {
self.get(&format!("/token/{mint}/dev-profile"), &[]).await
}
pub async fn token_best_traders(&self, mint: &str) -> Result<Value> {
self.get(&format!("/token/{mint}/best-traders"), &[]).await
}
pub async fn token_early_buyers(&self, mint: &str, hours: f32) -> Result<Value> {
self.get(&format!("/token/{mint}/early-buyers"), &[("hours", hours.to_string())]).await
}
pub async fn wallet_profile(&self, addr: &str) -> Result<Value> {
self.get(&format!("/wallet/{addr}"), &[]).await
}
pub async fn wallet_history(&self, addr: &str, opts: HistoryOptions) -> Result<Value> {
let mut q: Vec<(&str, String)> = vec![
("chain", opts.chain.unwrap_or_default().as_str().into()),
];
if let Some(limit) = opts.limit { q.push(("limit", limit.to_string())); }
if let Some(ty) = opts.ty { q.push(("type", ty.as_str().into())); }
if let Some(source) = opts.source { q.push(("source", source.as_str().into())); }
if let Some(before) = opts.before { q.push(("before", before)); }
self.get(&format!("/wallet/{addr}/history"), &q).await
}
pub async fn wallet_connections(&self, addr: &str, opts: ConnectionsOptions) -> Result<Value> {
let mut q: Vec<(&str, String)> = vec![];
if let Some(min_sol) = opts.min_sol { q.push(("min_sol", min_sol.to_string())); }
if let Some(max_pages) = opts.max_pages { q.push(("max_pages", max_pages.to_string())); }
self.get(&format!("/wallet/{addr}/connections"), &q).await
}
pub async fn wallets_batch_identity(&self, addresses: &[String]) -> Result<Value> {
self.post("/wallets/batch-identity", &serde_json::json!({ "addresses": addresses })).await
}
pub async fn cross_holders(&self, tokens: &[String]) -> Result<Value> {
self.post("/tokens/cross-holders", &serde_json::json!({ "tokens": tokens })).await
}
pub async fn cross_traders(&self, tokens: &[String]) -> Result<Value> {
self.post("/tokens/cross-traders", &serde_json::json!({ "tokens": tokens })).await
}
pub async fn chain_status(&self) -> Result<Value> {
self.get("/chain/status", &[]).await
}
pub async fn account(&self, addr: &str) -> Result<Value> {
self.get(&format!("/account/{addr}"), &[]).await
}
pub async fn accounts_batch(&self, addresses: &[String]) -> Result<Value> {
self.post("/accounts/batch", &serde_json::json!({ "addresses": addresses })).await
}
pub async fn parse_transactions(&self, signatures: &[String]) -> Result<Value> {
self.post("/transactions/parse", &serde_json::json!({ "transactions": signatures })).await
}
pub fn stream_pumpfun_new_tokens(&self) -> impl Stream<Item = Result<Value>> + 'static {
self.sse_stream("/stream/pumpfun/new-tokens")
}
pub fn stream_pumpfun_migrations(&self) -> impl Stream<Item = Result<Value>> + 'static {
self.sse_stream("/stream/pumpfun/migrations")
}
pub fn stream_raydium_new_pools(&self) -> impl Stream<Item = Result<Value>> + 'static {
self.sse_stream("/stream/raydium/new-pools")
}
pub fn stream_meteora_new_pools(&self) -> impl Stream<Item = Result<Value>> + 'static {
self.sse_stream("/stream/meteora/new-pools")
}
fn sse_stream(&self, path: &str) -> impl Stream<Item = Result<Value>> + 'static {
let url = format!("{}/api/v1{}", self.base_url, path);
let http = self.http.clone();
let api_key = self.api_key.clone();
stream! {
let res = match http.get(&url)
.header("X-API-Key", &api_key)
.header("Accept", "text/event-stream")
.send()
.await
{
Ok(r) => r,
Err(e) => { yield Err(Error::Http(e)); return; }
};
if !res.status().is_success() {
yield Err(error_from_response(res).await);
return;
}
let mut bytes_stream = res.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = bytes_stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => { yield Err(Error::Http(e)); return; }
};
buf.extend_from_slice(&chunk);
while let Some(idx) = buf.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = buf.drain(..=idx).collect();
let Ok(line) = std::str::from_utf8(&line_bytes) else { continue };
let line = line.trim_end_matches(['\r', '\n']);
let Some(payload) = line.strip_prefix("data:") else { continue };
let payload = payload.trim();
if payload.is_empty() { continue; }
match serde_json::from_str::<Value>(payload) {
Ok(v) => yield Ok(v),
Err(_) => yield Ok(serde_json::json!({ "raw": payload })),
}
}
}
}
}
}