#![warn(missing_debug_implementations)]
#![warn(rust_2018_idioms)]
mod client;
pub mod api;
pub mod error;
pub mod types;
use std::sync::Arc;
use crate::api::{
alpha_wallets::AlphaWallets, copytrade::CopyTrade, deployer_hunter::DeployerHunter, kol::Kol,
price_alerts::PriceAlerts, stream::Stream, tokens::Tokens, trades::Trades,
wallet::Wallet,
};
use crate::client::HttpCore;
use crate::error::{Result, RobinhoodChainError};
pub use crate::error::RobinhoodChainError as Error;
#[derive(Debug, Clone)]
pub struct RobinhoodChain {
pub kol: Kol,
pub trades: Trades,
pub tokens: Tokens,
pub deployer_hunter: DeployerHunter,
pub alpha_wallets: AlphaWallets,
pub wallet: Wallet,
pub copytrade: CopyTrade,
pub price_alerts: PriceAlerts,
pub stream: Stream,
}
impl RobinhoodChain {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
let api_key = api_key.into();
if !api_key.starts_with("msk_") {
eprintln!(
"\n[robinhood-chain] Missing or invalid API key.\n\
→ Get a free key at https://madeonsol.com/pricing (RHC bundled into every tier)\n\
→ Then: robinhood_chain::RobinhoodChain::new(std::env::var(\"MADEONSOL_API_KEY\")?)?\n"
);
return Err(RobinhoodChainError::MissingApiKey);
}
let core = Arc::new(HttpCore::new(api_key));
Ok(Self {
kol: Kol { core: Arc::clone(&core) },
trades: Trades { core: Arc::clone(&core) },
tokens: Tokens { core: Arc::clone(&core) },
deployer_hunter: DeployerHunter { core: Arc::clone(&core) },
alpha_wallets: AlphaWallets { core: Arc::clone(&core) },
wallet: Wallet { core: Arc::clone(&core) },
copytrade: CopyTrade { core: Arc::clone(&core) },
price_alerts: PriceAlerts { core: Arc::clone(&core) },
stream: Stream { core },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_missing_api_key() {
let err = RobinhoodChain::new("").unwrap_err();
assert!(matches!(err, RobinhoodChainError::MissingApiKey));
}
#[test]
fn rejects_wrong_prefix() {
let err = RobinhoodChain::new("sk_live_abc").unwrap_err();
assert!(matches!(err, RobinhoodChainError::MissingApiKey));
}
#[test]
fn accepts_valid_prefix() {
let client = RobinhoodChain::new("msk_test_abcdef").unwrap();
let _cloned = client.clone();
}
#[test]
fn patch_omits_untouched_fields() {
let body = serde_json::to_string(&types::CopyTradeUpdateParams::default()).unwrap();
assert_eq!(body, "{}");
}
#[test]
fn patch_distinguishes_omit_from_explicit_null() {
let clear = serde_json::to_value(&types::PriceAlertUpdateParams {
name: Some(None),
is_active: Some(false),
..Default::default()
})
.unwrap();
assert_eq!(clear["name"], serde_json::Value::Null);
assert!(clear.get("name").is_some(), "explicit null must be sent");
assert!(clear.get("webhook_url").is_none(), "omitted key must not be sent");
let set = serde_json::to_value(&types::PriceAlertUpdateParams {
name: Some(Some("renamed".into())),
..Default::default()
})
.unwrap();
assert_eq!(set["name"], "renamed");
}
#[test]
fn rule_engine_enums_match_wire_literals() {
use types::*;
assert_eq!(
serde_json::to_string(&DeliveryMode::Websocket).unwrap(),
"\"websocket\""
);
assert_eq!(
serde_json::to_string(&CopyTradeSizingMode::PercentSource).unwrap(),
"\"percent_source\""
);
assert_eq!(
serde_json::to_string(&FirstTouchStrategy::DayTrader).unwrap(),
"\"day_trader\""
);
assert_eq!(DeliveryMode::Both.as_str(), "both");
assert_eq!(PriceAlertStatus::Watching.as_str(), "watching");
assert_eq!(PriceAlertEventType::Recovery.as_str(), "recovery");
assert_eq!(CopyTradeOnlyAction::Sell.as_str(), "sell");
}
#[test]
fn fire_history_parses_without_count() {
let signals: types::CopyTradeSignalsResponse =
serde_json::from_str(r#"{"chain":"robinhood","signals":[]}"#).unwrap();
assert_eq!(signals.count, 0);
let events: types::PriceAlertEventsResponse =
serde_json::from_str(r#"{"chain":"robinhood","events":[]}"#).unwrap();
assert_eq!(events.count, 0);
}
#[test]
fn lp_event_v4_row_parses_with_null_amounts() {
let resp: types::LpEventsResponse = serde_json::from_str(
r#"{"chain":"robinhood","events":[{"event":"remove","pool":"0xabc","dex":"uniswap-v4",
"fee_tier":null,"token_address":"0x1111111111111111111111111111111111111111",
"token_symbol":null,"token_name":null,"token_decimals":18,"launchpad":null,
"provider":"0x2222222222222222222222222222222222222222","provider_is_token_deployer":true,
"provider_deployer_tier":null,"provider_kol_name":null,
"liquidity":"340282366920938463463374607431768211455","amount0":null,"amount1":null,
"token0":null,"token1":null,"token_amount_raw":null,"quote_token":null,"quote_amount_raw":null,
"block_number":123,"block_time":"2026-08-16T00:00:00Z","tx_hash":"0xdead","log_index":4}],
"count":1,"has_more":false,"next_before":null,
"coverage":{"events":["remove"],"adds_persisted":false,"note":"x","since":"2026-08-05"}}"#,
)
.unwrap();
let ev = &resp.events[0];
assert_eq!(ev.event, "remove");
assert!(ev.provider_is_token_deployer);
assert_eq!(ev.liquidity.as_deref(), Some("340282366920938463463374607431768211455"));
assert!(ev.amount0.is_none() && ev.token_amount_raw.is_none());
assert!(!resp.coverage.as_ref().unwrap().adds_persisted);
assert_eq!(
serde_json::to_string(&types::LpEventsParams { dex: Some(types::TradeDex::UniswapV4), ..Default::default() }).unwrap(),
r#"{"dex":"uniswap-v4"}"#
);
}
#[test]
fn equities_sort_and_unpriced_row() {
assert_eq!(serde_json::to_string(&types::EquitiesSort::MarketCap).unwrap(), "\"market_cap\"");
assert_eq!(types::EquitiesSort::LastTrade.as_str(), "last_trade");
let resp: types::EquitiesResponse = serde_json::from_str(
r#"{"chain":"robinhood","equities":[{"token_address":"0x3333333333333333333333333333333333333333",
"symbol":"NVDA","name":"NVIDIA","onchain_name":"NVIDIA • Robinhood Token","asset_class":"equity",
"verified":true,"issuer_beacon":"0xe10b6f6b275de231345c20d14ab812db62151b00","decimals":18,
"listed_at":null,"price_usd":null,"price_native":null,"market_cap_usd":null,"fdv_usd":null,
"peak_mc_usd":null,"liquidity_usd":null,"liquidity_basis":null,"primary_dex":null,
"primary_pool":null,"last_trade_time":null,"trades_24h":0,"volume_eth_24h":0,"buys_24h":0,
"sells_24h":0,"buyers_24h":0,"sellers_24h":0}],
"count":1,"total_equities":157,"sort":"volume",
"identity":{"method":"beacon","issuer_beacon":"0xe10b6f6b275de231345c20d14ab812db62151b00","note":"n"},
"stats_window":"24h","stats_as_of":"2026-08-16T00:00:00Z"}"#,
)
.unwrap();
let e = &resp.equities[0];
assert_eq!(e.symbol.as_deref(), Some("NVDA"));
assert!(e.verified && e.price_usd.is_none());
assert_eq!(resp.total_equities, 157);
assert_eq!(resp.identity.as_ref().unwrap().method.as_deref(), Some("beacon"));
}
#[test]
fn first_touch_filters_round_trip() {
let sub: types::RhcFirstTouchSubscription = serde_json::from_str(
r#"{"id":"11111111-1111-1111-1111-111111111111","name":null,"filters":{},
"delivery_mode":"websocket","webhook_url":null,"is_active":true,
"created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z"}"#,
)
.unwrap();
assert!(sub.filters.kol.is_none());
assert_eq!(sub.delivery_mode, types::DeliveryMode::Websocket);
assert_eq!(
serde_json::to_string(&types::FirstTouchFilters::default()).unwrap(),
"{}"
);
}
#[test]
fn stream_token_deserializes_null_expiry() {
let t: crate::types::StreamToken = serde_json::from_str(
r#"{"token":"abc","expires_at":null,"next_refresh_at":null,"rotated":false,
"lifetime":"This token does not expire.",
"ws_url":"wss://madeonsol.com/ws/v1/stream","usage":"connect"}"#,
)
.unwrap();
assert_eq!(t.token, "abc");
assert!(t.expires_at.is_none());
assert!(t.next_refresh_at.is_none());
assert_eq!(t.rotated, Some(false));
assert!(t.lifetime.is_some());
assert!(t.dex_ws_url.is_none());
let old: crate::types::StreamToken = serde_json::from_str(
r#"{"token":"abc","expires_at":"2026-08-28T00:00:00Z",
"ws_url":"wss://madeonsol.com/ws/v1/stream","usage":"connect"}"#,
)
.unwrap();
assert_eq!(old.expires_at.as_deref(), Some("2026-08-28T00:00:00Z"));
assert!(old.rotated.is_none());
assert!(old.lifetime.is_none());
}
}