robinhood_chain/lib.rs
1//! # robinhood-chain — Robinhood Chain SDK for Rust
2//!
3//! EVM-native on-chain trading intelligence for **Robinhood Chain (chain id 4663)**:
4//! live KOL trades, token discovery & launch-bundle detection, deployer reputation,
5//! smart-money wallet ranking, OHLC candles, and the DEX trade tape — all from our
6//! self-hosted node.
7//!
8//! Robinhood Chain is an Arbitrum Orbit L2, so every field is EVM-native:
9//! `token_address` (lowercase `0x…`), `eth_amount`, `tx_hash`, `block_number`,
10//! `net_flow_eth`. There are no Solana field names here.
11//!
12//! ## Get an API key
13//!
14//! Robinhood Chain coverage is **bundled into every MadeOnSol tier at no extra cost** —
15//! same `msk_` key, same base URL. Get a free key at <https://madeonsol.com/pricing>.
16//! Paid tiers (PRO / ULTRA) unlock the DEX trade tape, token discovery, candles,
17//! KOL-consensus, alpha-wallet ranking, and WebSocket streaming — and new customers
18//! get a **3-day free trial** of Pro or Ultra when paying by card. See
19//! <https://madeonsol.com/pricing>.
20//!
21//! ## Quick start
22//!
23//! ```no_run
24//! use robinhood_chain::{RobinhoodChain, types::KolFeedParams};
25//!
26//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
27//! let api_key = std::env::var("MADEONSOL_API_KEY")?;
28//! let client = RobinhoodChain::new(api_key)?;
29//!
30//! let feed = client
31//! .kol
32//! .feed(&KolFeedParams { limit: Some(10), ..Default::default() })
33//! .await?;
34//!
35//! for trade in feed.trades {
36//! println!("{:?} {:?} {:?} ({} ETH)",
37//! trade.kol_name, trade.action, trade.token_symbol,
38//! trade.eth_amount.unwrap_or(0.0));
39//! }
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! ## Namespaces
45//!
46//! - [`RobinhoodChain::kol`] — KOL feed, leaderboard, consensus hot-tokens, coordination, first-touches, single-KOL profile, plus the coordination-alert (PRO+) and first-touch-subscription (ULTRA+) rule engines
47//! - [`RobinhoodChain::trades`] — the DEX trade tape (PRO+) + the liquidity-removals feed `lp_events` (PRO+, removals only)
48//! - [`RobinhoodChain::tokens`] — token discovery, beacon-verified tokenized equities, per-token snapshot, candles, KOL-consensus, buyer-quality, bundle, batch reads
49//! - [`RobinhoodChain::deployer_hunter`] — deployer reputation: leaderboard, profile, trajectory, launch history, best-tokens, stats, alerts, recent graduations
50//! - [`RobinhoodChain::alpha_wallets`] — smart-money wallet ranking (PRO+)
51//! - [`RobinhoodChain::wallet`] — wallet profile, FIFO PnL, positions, tape, watchlist (PRO+)
52//! - [`RobinhoodChain::copytrade`] — copy-trade rules + fired-signal history (PRO+)
53//! - [`RobinhoodChain::price_alerts`] — price alerts + dip/recovery events (PRO+)
54//! - [`RobinhoodChain::stream`] — WebSocket streaming token issuance + the six `rhc:*` channels (`rhc:kol_trades`, `rhc:dex_trades` (ULTRA+), and the four rule-engine channels)
55//!
56//! ## Push rule engines
57//!
58//! Four rule engines turn the read endpoints into push: copy-trade, price
59//! alerts, KOL coordination and KOL first-touches. Each rule delivers by
60//! webhook (HMAC-SHA256 signed), WebSocket, or both, and each keeps a
61//! queryable fire history for catch-up after a missed delivery.
62//!
63//! ⚠️ **Quotas are PER CHAIN** — a full set of Solana rules does not consume any
64//! Robinhood Chain capacity. Two RHC-specific differences worth knowing before
65//! you port Solana code: RHC price alerts are **~15s polled, not sub-second**,
66//! and RHC copy-trade rules have **no market-cap band**.
67//!
68//! Full API reference: <https://madeonsol.com/api-docs> · Robinhood Chain overview:
69//! <https://madeonsol.com/robinhood>
70
71#![warn(missing_debug_implementations)]
72#![warn(rust_2018_idioms)]
73
74mod client;
75pub mod api;
76pub mod error;
77pub mod types;
78
79use std::sync::Arc;
80
81use crate::api::{
82 alpha_wallets::AlphaWallets, copytrade::CopyTrade, deployer_hunter::DeployerHunter, kol::Kol,
83 price_alerts::PriceAlerts, stream::Stream, tokens::Tokens, trades::Trades,
84 wallet::Wallet,
85};
86use crate::client::HttpCore;
87use crate::error::{Result, RobinhoodChainError};
88
89pub use crate::error::RobinhoodChainError as Error;
90
91/// Robinhood Chain API client.
92///
93/// Construct with [`RobinhoodChain::new`] and a `msk_…` API key, then access the
94/// namespaced sub-clients ([`kol`](Self::kol), [`tokens`](Self::tokens), etc.).
95///
96/// Cheap to clone — internal HTTP state is reference-counted.
97///
98/// # Example
99///
100/// ```no_run
101/// use robinhood_chain::RobinhoodChain;
102///
103/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
104/// let client = RobinhoodChain::new(std::env::var("MADEONSOL_API_KEY")?)?;
105/// let feed = client.kol.feed(&Default::default()).await?;
106/// println!("{} recent KOL trades on chain {}", feed.count, feed.chain);
107/// # Ok(())
108/// # }
109/// ```
110#[derive(Debug, Clone)]
111pub struct RobinhoodChain {
112 /// KOL trade intelligence: feed, leaderboard, consensus hot-tokens,
113 /// coordination, first-touches, profile.
114 pub kol: Kol,
115 /// The Robinhood Chain DEX trade tape + liquidity-removals feed (PRO+).
116 pub trades: Trades,
117 /// Token intelligence: discovery, beacon-verified tokenized equities,
118 /// snapshot, candles, KOL-consensus, buyer-quality, bundle, and the two
119 /// batch reads.
120 pub tokens: Tokens,
121 /// Deployer reputation: leaderboard, profile, trajectory, launch history,
122 /// best-tokens, chain-wide stats, alerts, recent graduations.
123 pub deployer_hunter: DeployerHunter,
124 /// Smart-money wallet ranking (PRO+).
125 pub alpha_wallets: AlphaWallets,
126 /// Wallet intelligence: 90-day ETH profile, FIFO PnL, open positions,
127 /// per-wallet tape, and the per-chain wallet watchlist (PRO+).
128 pub wallet: Wallet,
129 /// Copy-trade rule engine: rules + fired-signal history (PRO+).
130 pub copytrade: CopyTrade,
131 /// Price-alert rule engine: alerts + dip/recovery events (PRO+).
132 pub price_alerts: PriceAlerts,
133 /// WebSocket streaming token issuance + the six `rhc:*` channels
134 /// (`rhc:kol_trades`, `rhc:dex_trades` (ULTRA+), and the four rule-engine
135 /// channels — see [`api::stream`]).
136 pub stream: Stream,
137}
138
139impl RobinhoodChain {
140 /// Construct a new client.
141 ///
142 /// `api_key` must start with `msk_`. Robinhood Chain coverage is bundled into
143 /// every tier — get a free key at <https://madeonsol.com/pricing>.
144 ///
145 /// # Errors
146 ///
147 /// Returns [`RobinhoodChainError::MissingApiKey`] if the key is empty or
148 /// missing the `msk_` prefix.
149 pub fn new(api_key: impl Into<String>) -> Result<Self> {
150 let api_key = api_key.into();
151 if !api_key.starts_with("msk_") {
152 eprintln!(
153 "\n[robinhood-chain] Missing or invalid API key.\n\
154 → Get a free key at https://madeonsol.com/pricing (RHC bundled into every tier)\n\
155 → Then: robinhood_chain::RobinhoodChain::new(std::env::var(\"MADEONSOL_API_KEY\")?)?\n"
156 );
157 return Err(RobinhoodChainError::MissingApiKey);
158 }
159
160 let core = Arc::new(HttpCore::new(api_key));
161 Ok(Self {
162 kol: Kol { core: Arc::clone(&core) },
163 trades: Trades { core: Arc::clone(&core) },
164 tokens: Tokens { core: Arc::clone(&core) },
165 deployer_hunter: DeployerHunter { core: Arc::clone(&core) },
166 alpha_wallets: AlphaWallets { core: Arc::clone(&core) },
167 wallet: Wallet { core: Arc::clone(&core) },
168 copytrade: CopyTrade { core: Arc::clone(&core) },
169 price_alerts: PriceAlerts { core: Arc::clone(&core) },
170 stream: Stream { core },
171 })
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn rejects_missing_api_key() {
181 let err = RobinhoodChain::new("").unwrap_err();
182 assert!(matches!(err, RobinhoodChainError::MissingApiKey));
183 }
184
185 #[test]
186 fn rejects_wrong_prefix() {
187 let err = RobinhoodChain::new("sk_live_abc").unwrap_err();
188 assert!(matches!(err, RobinhoodChainError::MissingApiKey));
189 }
190
191 #[test]
192 fn accepts_valid_prefix() {
193 let client = RobinhoodChain::new("msk_test_abcdef").unwrap();
194 // Smoke test — namespaces exist and the client clones cheaply.
195 let _cloned = client.clone();
196 }
197
198 /// An all-default PATCH body must serialize to `{}` — never to a wall of
199 /// nulls that would clear every nullable column.
200 #[test]
201 fn patch_omits_untouched_fields() {
202 let body = serde_json::to_string(&types::CopyTradeUpdateParams::default()).unwrap();
203 assert_eq!(body, "{}");
204 }
205
206 /// `Option<Option<T>>` must distinguish "leave alone" from "set null".
207 #[test]
208 fn patch_distinguishes_omit_from_explicit_null() {
209 let clear = serde_json::to_value(&types::PriceAlertUpdateParams {
210 name: Some(None),
211 is_active: Some(false),
212 ..Default::default()
213 })
214 .unwrap();
215 assert_eq!(clear["name"], serde_json::Value::Null);
216 assert!(clear.get("name").is_some(), "explicit null must be sent");
217 assert!(clear.get("webhook_url").is_none(), "omitted key must not be sent");
218
219 let set = serde_json::to_value(&types::PriceAlertUpdateParams {
220 name: Some(Some("renamed".into())),
221 ..Default::default()
222 })
223 .unwrap();
224 assert_eq!(set["name"], "renamed");
225 }
226
227 /// Rule-engine enums must hit the exact wire literals the API validates on.
228 #[test]
229 fn rule_engine_enums_match_wire_literals() {
230 use types::*;
231 assert_eq!(
232 serde_json::to_string(&DeliveryMode::Websocket).unwrap(),
233 "\"websocket\""
234 );
235 assert_eq!(
236 serde_json::to_string(&CopyTradeSizingMode::PercentSource).unwrap(),
237 "\"percent_source\""
238 );
239 assert_eq!(
240 serde_json::to_string(&FirstTouchStrategy::DayTrader).unwrap(),
241 "\"day_trader\""
242 );
243 assert_eq!(DeliveryMode::Both.as_str(), "both");
244 assert_eq!(PriceAlertStatus::Watching.as_str(), "watching");
245 assert_eq!(PriceAlertEventType::Recovery.as_str(), "recovery");
246 assert_eq!(CopyTradeOnlyAction::Sell.as_str(), "sell");
247 }
248
249 /// The two fire-history endpoints omit `count` entirely when the caller owns
250 /// no rules at all — that must not be a parse failure.
251 #[test]
252 fn fire_history_parses_without_count() {
253 let signals: types::CopyTradeSignalsResponse =
254 serde_json::from_str(r#"{"chain":"robinhood","signals":[]}"#).unwrap();
255 assert_eq!(signals.count, 0);
256
257 let events: types::PriceAlertEventsResponse =
258 serde_json::from_str(r#"{"chain":"robinhood","events":[]}"#).unwrap();
259 assert_eq!(events.count, 0);
260 }
261
262 /// A v4 liquidity removal carries `liquidity` only — the amount fields are
263 /// null on the wire and must land as `None`, never as a parse failure. Raw
264 /// amounts stay decimal strings (uint256 does not fit an f64).
265 #[test]
266 fn lp_event_v4_row_parses_with_null_amounts() {
267 let resp: types::LpEventsResponse = serde_json::from_str(
268 r#"{"chain":"robinhood","events":[{"event":"remove","pool":"0xabc","dex":"uniswap-v4",
269 "fee_tier":null,"token_address":"0x1111111111111111111111111111111111111111",
270 "token_symbol":null,"token_name":null,"token_decimals":18,"launchpad":null,
271 "provider":"0x2222222222222222222222222222222222222222","provider_is_token_deployer":true,
272 "provider_deployer_tier":null,"provider_kol_name":null,
273 "liquidity":"340282366920938463463374607431768211455","amount0":null,"amount1":null,
274 "token0":null,"token1":null,"token_amount_raw":null,"quote_token":null,"quote_amount_raw":null,
275 "block_number":123,"block_time":"2026-08-16T00:00:00Z","tx_hash":"0xdead","log_index":4}],
276 "count":1,"has_more":false,"next_before":null,
277 "coverage":{"events":["remove"],"adds_persisted":false,"note":"x","since":"2026-08-05"}}"#,
278 )
279 .unwrap();
280 let ev = &resp.events[0];
281 assert_eq!(ev.event, "remove");
282 assert!(ev.provider_is_token_deployer);
283 assert_eq!(ev.liquidity.as_deref(), Some("340282366920938463463374607431768211455"));
284 assert!(ev.amount0.is_none() && ev.token_amount_raw.is_none());
285 assert!(!resp.coverage.as_ref().unwrap().adds_persisted);
286 assert_eq!(
287 serde_json::to_string(&types::LpEventsParams { dex: Some(types::TradeDex::UniswapV4), ..Default::default() }).unwrap(),
288 r#"{"dex":"uniswap-v4"}"#
289 );
290 }
291
292 /// Equities sort keys hit the exact wire literals; an unpriced equity parses
293 /// with `None` numerics and zeroed 24h counters.
294 #[test]
295 fn equities_sort_and_unpriced_row() {
296 assert_eq!(serde_json::to_string(&types::EquitiesSort::MarketCap).unwrap(), "\"market_cap\"");
297 assert_eq!(types::EquitiesSort::LastTrade.as_str(), "last_trade");
298 let resp: types::EquitiesResponse = serde_json::from_str(
299 r#"{"chain":"robinhood","equities":[{"token_address":"0x3333333333333333333333333333333333333333",
300 "symbol":"NVDA","name":"NVIDIA","onchain_name":"NVIDIA • Robinhood Token","asset_class":"equity",
301 "verified":true,"issuer_beacon":"0xe10b6f6b275de231345c20d14ab812db62151b00","decimals":18,
302 "listed_at":null,"price_usd":null,"price_native":null,"market_cap_usd":null,"fdv_usd":null,
303 "peak_mc_usd":null,"liquidity_usd":null,"liquidity_basis":null,"primary_dex":null,
304 "primary_pool":null,"last_trade_time":null,"trades_24h":0,"volume_eth_24h":0,"buys_24h":0,
305 "sells_24h":0,"buyers_24h":0,"sellers_24h":0}],
306 "count":1,"total_equities":157,"sort":"volume",
307 "identity":{"method":"beacon","issuer_beacon":"0xe10b6f6b275de231345c20d14ab812db62151b00","note":"n"},
308 "stats_window":"24h","stats_as_of":"2026-08-16T00:00:00Z"}"#,
309 )
310 .unwrap();
311 let e = &resp.equities[0];
312 assert_eq!(e.symbol.as_deref(), Some("NVDA"));
313 assert!(e.verified && e.price_usd.is_none());
314 assert_eq!(resp.total_equities, 157);
315 assert_eq!(resp.identity.as_ref().unwrap().method.as_deref(), Some("beacon"));
316 }
317
318 /// A first-touch subscription with no filters comes back as `{}`, and an
319 /// empty filter set must serialize to `{}` rather than a null soup.
320 #[test]
321 fn first_touch_filters_round_trip() {
322 let sub: types::RhcFirstTouchSubscription = serde_json::from_str(
323 r#"{"id":"11111111-1111-1111-1111-111111111111","name":null,"filters":{},
324 "delivery_mode":"websocket","webhook_url":null,"is_active":true,
325 "created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z"}"#,
326 )
327 .unwrap();
328 assert!(sub.filters.kol.is_none());
329 assert_eq!(sub.delivery_mode, types::DeliveryMode::Websocket);
330 assert_eq!(
331 serde_json::to_string(&types::FirstTouchFilters::default()).unwrap(),
332 "{}"
333 );
334 }
335
336 /// Regression: since 2026-08-27 `POST /stream/token` returns
337 /// `expires_at: null` / `next_refresh_at: null` (stream tokens never
338 /// expire) plus `rotated` / `lifetime`. 0.8.0's
339 /// `expires_at: String` refused that body, so `get_token()` errored for
340 /// every caller.
341 #[test]
342 fn stream_token_deserializes_null_expiry() {
343 let t: crate::types::StreamToken = serde_json::from_str(
344 r#"{"token":"abc","expires_at":null,"next_refresh_at":null,"rotated":false,
345 "lifetime":"This token does not expire.",
346 "ws_url":"wss://madeonsol.com/ws/v1/stream","usage":"connect"}"#,
347 )
348 .unwrap();
349 assert_eq!(t.token, "abc");
350 assert!(t.expires_at.is_none());
351 assert!(t.next_refresh_at.is_none());
352 assert_eq!(t.rotated, Some(false));
353 assert!(t.lifetime.is_some());
354 assert!(t.dex_ws_url.is_none());
355
356 // Pre-2026-08-27 servers sent a timestamp and omitted the new fields.
357 let old: crate::types::StreamToken = serde_json::from_str(
358 r#"{"token":"abc","expires_at":"2026-08-28T00:00:00Z",
359 "ws_url":"wss://madeonsol.com/ws/v1/stream","usage":"connect"}"#,
360 )
361 .unwrap();
362 assert_eq!(old.expires_at.as_deref(), Some("2026-08-28T00:00:00Z"));
363 assert!(old.rotated.is_none());
364 assert!(old.lifetime.is_none());
365 }
366}