Skip to main content

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}