madeonsol/lib.rs
1//! # MadeOnSol — official Rust SDK
2//!
3//! Solana KOL wallet tracking, Pump.fun deployer intelligence, alpha-wallet scoring,
4//! and an all-DEX trade firehose.
5//!
6//! ## Get an API key
7//!
8//! Free tier: **200 requests/day, no credit card** at <https://madeonsol.com/pricing>.
9//! Paid tiers (PRO $49/mo, ULTRA $149/mo) unlock higher rate limits, sub-hour windows,
10//! WebSocket streaming, webhooks, and the all-DEX firehose.
11//!
12//! All keys start with `msk_`.
13//!
14//! ## Quick start
15//!
16//! ```no_run
17//! use madeonsol::{MadeOnSol, types::KolFeedParams};
18//!
19//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
20//! let api_key = std::env::var("MADEONSOL_API_KEY")?;
21//! let client = MadeOnSol::new(api_key)?;
22//!
23//! let feed = client
24//! .kol
25//! .feed(&KolFeedParams { limit: Some(10), ..Default::default() })
26//! .await?;
27//!
28//! for trade in feed.trades {
29//! println!("{:?} bought {:?}", trade.kol_name, trade.token_symbol);
30//! }
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! ## Namespaces
36//!
37//! - [`MadeOnSol::kol`] — KOL feed, leaderboard, coordination, PnL, trending tokens, alerts, scout leaderboard
38//! - [`MadeOnSol::deployer`] — Pump.fun deployer leaderboard, alerts, trajectory
39//! - [`MadeOnSol::alpha`] — alpha-wallet leaderboard, profiles, cap tables, buyer quality
40//! - [`MadeOnSol::wallet_tracker`] — track arbitrary Solana wallets (watchlist)
41//! - [`MadeOnSol::wallet`] — universal wallet stats, FIFO PnL, open positions, paginated trades (PRO+)
42//! - [`MadeOnSol::coordination_alerts`] — push alerts on coordinated buying (PRO/ULTRA)
43//! - [`MadeOnSol::price_alerts`] — price-drop / recovery alert rules CRUD (PRO/ULTRA)
44//! - [`MadeOnSol::signals`] — Signal Scorecard: out-of-sample, machine-readable signal reliability
45//! - [`MadeOnSol::tools`] — Solana tool directory search
46//! - [`MadeOnSol::stream`] — WebSocket streaming token issuance + live session list/kill
47//! - [`MadeOnSol::webhooks`] — webhook CRUD (PRO/ULTRA)
48//!
49//! Full API reference: <https://madeonsol.com/api-docs>
50
51#![warn(missing_debug_implementations)]
52#![warn(rust_2018_idioms)]
53
54mod client;
55pub mod api;
56pub mod error;
57pub mod types;
58
59use std::sync::Arc;
60
61use crate::api::{
62 alpha::Alpha, coordination_alerts::CoordinationAlerts, deployer::Deployer,
63 first_touch_subscriptions::FirstTouchSubscriptions, kol::Kol, me::Me,
64 price_alerts::PriceAlerts, signals::Signals, sniper::Sniper, stream::Stream, token::Token,
65 tools::Tools, wallet::Wallet, wallet_tracker::WalletTracker, webhooks::Webhooks,
66};
67use crate::client::HttpCore;
68use crate::error::{MadeOnSolError, Result};
69
70pub use crate::error::MadeOnSolError as Error;
71
72/// MadeOnSol API client.
73///
74/// Construct with [`MadeOnSol::new`] and a `msk_…` API key, then access the
75/// namespaced sub-clients ([`kol`](Self::kol), [`deployer`](Self::deployer), etc.).
76///
77/// Cheap to clone — internal HTTP state is reference-counted.
78///
79/// # Example
80///
81/// ```no_run
82/// use madeonsol::MadeOnSol;
83///
84/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
85/// let client = MadeOnSol::new(std::env::var("MADEONSOL_API_KEY")?)?;
86/// let stats = client.deployer.stats().await?;
87/// println!("{} deployers tracked", stats.tracked_count);
88/// # Ok(())
89/// # }
90/// ```
91#[derive(Debug, Clone)]
92pub struct MadeOnSol {
93 /// KOL wallet tracking endpoints.
94 pub kol: Kol,
95 /// Pump.fun deployer intelligence endpoints.
96 pub deployer: Deployer,
97 /// Alpha wallet intelligence: leaderboard, profiles, cap tables, buyer quality.
98 pub alpha: Alpha,
99 /// Token intelligence — comprehensive per-mint snapshot + batch lookups.
100 pub token: Token,
101 /// Account self-inspection — tier, quota, feature usage (v0.8).
102 pub me: Me,
103 /// Wallet tracker: watchlist CRUD, trades, summary.
104 pub wallet_tracker: WalletTracker,
105 /// Universal wallet endpoints — stats, FIFO PnL, open positions, paginated trades for any Solana wallet. PRO+.
106 pub wallet: Wallet,
107 /// Coordination alert rules CRUD (v1.1) — PRO/ULTRA.
108 pub coordination_alerts: CoordinationAlerts,
109 /// First-touch webhook subscriptions CRUD — ULTRA only. Use `kol.first_touches()` for read-only queries.
110 pub first_touch_subscriptions: FirstTouchSubscriptions,
111 /// Price-drop / recovery alert rules CRUD (v1.9) — PRO/ULTRA.
112 pub price_alerts: PriceAlerts,
113 /// Signal Scorecard (v0.16) — out-of-sample, machine-readable signal reliability + catalog.
114 pub signals: Signals,
115 /// Deshred pre-confirm pump.fun sniper feed + custom watchlist — PRO/ULTRA.
116 pub sniper: Sniper,
117 /// Solana tool directory search.
118 pub tools: Tools,
119 /// WebSocket streaming token issuance.
120 pub stream: Stream,
121 /// Webhook management (PRO/ULTRA).
122 pub webhooks: Webhooks,
123}
124
125impl MadeOnSol {
126 /// Construct a new client.
127 ///
128 /// `api_key` must start with `msk_`. Get a free key (200 req/day, no card)
129 /// at <https://madeonsol.com/pricing>.
130 ///
131 /// # Errors
132 ///
133 /// Returns [`MadeOnSolError::MissingApiKey`] if the key is empty or missing the
134 /// `msk_` prefix. The error message includes the signup URL so end users know
135 /// where to go.
136 pub fn new(api_key: impl Into<String>) -> Result<Self> {
137 let api_key = api_key.into();
138 if !api_key.starts_with("msk_") {
139 // Print to stderr too — a bare Err can be swallowed and the user
140 // never sees the link to /pricing.
141 eprintln!(
142 "\n[madeonsol] Missing or invalid API key.\n\
143 → Get a free key (200 req/day, no card) at https://madeonsol.com/pricing\n\
144 → Then: madeonsol::MadeOnSol::new(std::env::var(\"MADEONSOL_API_KEY\")?)?\n"
145 );
146 return Err(MadeOnSolError::MissingApiKey);
147 }
148
149 let core = Arc::new(HttpCore::new(api_key));
150 Ok(Self {
151 kol: Kol { core: Arc::clone(&core) },
152 deployer: Deployer { core: Arc::clone(&core) },
153 alpha: Alpha { core: Arc::clone(&core) },
154 token: Token { core: Arc::clone(&core) },
155 me: Me { core: Arc::clone(&core) },
156 wallet_tracker: WalletTracker { core: Arc::clone(&core) },
157 wallet: Wallet { core: Arc::clone(&core) },
158 coordination_alerts: CoordinationAlerts { core: Arc::clone(&core) },
159 first_touch_subscriptions: FirstTouchSubscriptions { core: Arc::clone(&core) },
160 price_alerts: PriceAlerts { core: Arc::clone(&core) },
161 signals: Signals { core: Arc::clone(&core) },
162 sniper: Sniper { core: Arc::clone(&core) },
163 tools: Tools { core: Arc::clone(&core) },
164 stream: Stream { core: Arc::clone(&core) },
165 webhooks: Webhooks { core },
166 })
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn rejects_missing_api_key() {
176 let err = MadeOnSol::new("").unwrap_err();
177 assert!(matches!(err, MadeOnSolError::MissingApiKey));
178 }
179
180 #[test]
181 fn rejects_wrong_prefix() {
182 let err = MadeOnSol::new("sk_live_abc").unwrap_err();
183 assert!(matches!(err, MadeOnSolError::MissingApiKey));
184 }
185
186 #[test]
187 fn accepts_valid_prefix() {
188 let client = MadeOnSol::new("msk_test_abcdef").unwrap();
189 // Smoke test — namespaces exist and the client clones cheaply.
190 let _cloned = client.clone();
191 }
192
193 /// Regression: since 2026-08-27 `POST /stream/token` returns
194 /// `expires_at: null` / `next_refresh_at: null` (stream tokens never
195 /// expire) plus `rotated` / `lifetime`. 0.26.0's
196 /// `expires_at: String` refused that body, so `get_token()` errored for
197 /// every caller.
198 #[test]
199 fn stream_token_deserializes_null_expiry() {
200 let t: crate::types::StreamToken = serde_json::from_str(
201 r#"{"token":"abc","expires_at":null,"next_refresh_at":null,"rotated":false,
202 "lifetime":"This token does not expire.",
203 "ws_url":"wss://madeonsol.com/ws/v1/stream","usage":"connect"}"#,
204 )
205 .unwrap();
206 assert_eq!(t.token, "abc");
207 assert!(t.expires_at.is_none());
208 assert!(t.next_refresh_at.is_none());
209 assert_eq!(t.rotated, Some(false));
210 assert!(t.lifetime.is_some());
211 assert!(t.dex_ws_url.is_none());
212
213 // Pre-2026-08-27 servers sent a timestamp and omitted the new fields.
214 let old: crate::types::StreamToken = serde_json::from_str(
215 r#"{"token":"abc","expires_at":"2026-08-28T00:00:00Z",
216 "ws_url":"wss://madeonsol.com/ws/v1/stream","usage":"connect"}"#,
217 )
218 .unwrap();
219 assert_eq!(old.expires_at.as_deref(), Some("2026-08-28T00:00:00Z"));
220 assert!(old.rotated.is_none());
221 assert!(old.lifetime.is_none());
222 }
223}