use super::exchange::ExchangeClient;
use super::registry::{VenueRegistry, unknown_venue};
use crate::chains::chain_metadata;
use crate::error::{Result, ScopeError};
use serde::Serialize;
pub const VENUE_HELP: &str = "Market venue: a CEX id (run `scope venues list`), or a DEX: \
ethereum (eth) or solana (sol), whose book is built from the token's most liquid pool.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DexVenue {
Ethereum,
Solana,
}
impl DexVenue {
pub const ALL: [DexVenue; 2] = [DexVenue::Ethereum, DexVenue::Solana];
pub fn parse(input: &str) -> Option<Self> {
let chain = chain_metadata(input.trim())?.chain_id;
Self::ALL.into_iter().find(|venue| venue.chain() == chain)
}
pub fn id(self) -> &'static str {
self.chain()
}
pub fn chain(self) -> &'static str {
match self {
Self::Ethereum => "ethereum",
Self::Solana => "solana",
}
}
pub fn name(self) -> &'static str {
match self {
Self::Ethereum => "Ethereum DEX",
Self::Solana => "Solana DEX",
}
}
pub fn require_chain(self, chain: &str) -> Result<()> {
if Self::parse(chain) == Some(self) {
return Ok(());
}
Err(ScopeError::InvalidInput(format!(
"DEX venue '{}' reads chain {}, not {}",
self.id(),
self.chain(),
chain
)))
}
}
pub enum MarketVenue {
Dex(DexVenue),
Cex(Box<ExchangeClient>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VenueKind {
Cex,
Dex,
}
#[derive(Debug, Clone, Serialize)]
pub struct VenueListing {
pub id: String,
pub name: String,
pub kind: VenueKind,
pub capabilities: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain: Option<&'static str>,
}
impl MarketVenue {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `MarketVenue::resolve_with_http`"
)]
pub fn resolve(input: &str, registry: &VenueRegistry) -> Result<Self> {
Self::resolve_with_http(input, registry, crate::http::shared::fallback())
}
pub fn resolve_with_http(
input: &str,
registry: &VenueRegistry,
http: std::sync::Arc<dyn crate::http::HttpClient>,
) -> Result<Self> {
if let Some(dex) = DexVenue::parse(input) {
return Ok(Self::Dex(dex));
}
if registry.contains(input) {
return Ok(Self::Cex(Box::new(
registry.create_exchange_client_with_http(input, http)?,
)));
}
Err(unknown_venue(input, &Self::ids(registry)))
}
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `MarketVenue::from_name_with_http`"
)]
pub fn from_name(input: &str) -> Result<Self> {
Self::from_name_with_http(input, crate::http::shared::fallback())
}
pub fn from_name_with_http(
input: &str,
http: std::sync::Arc<dyn crate::http::HttpClient>,
) -> Result<Self> {
Self::resolve_with_http(input, &VenueRegistry::load()?, http)
}
pub fn require_chain(&self, chain: &str) -> Result<()> {
match self {
Self::Dex(dex) => dex.require_chain(chain),
Self::Cex(_) => Ok(()),
}
}
pub fn listings(registry: &VenueRegistry) -> Vec<VenueListing> {
let cex = registry.list().into_iter().filter_map(|id| {
registry.get(id).map(|desc| VenueListing {
id: desc.id.clone(),
name: desc.name.clone(),
kind: VenueKind::Cex,
capabilities: desc.capability_names(),
base_url: Some(desc.base_url.clone()),
chain: None,
})
});
let dex = DexVenue::ALL.into_iter().map(|venue| VenueListing {
id: venue.id().to_string(),
name: venue.name().to_string(),
kind: VenueKind::Dex,
capabilities: vec!["order_book"],
base_url: None,
chain: Some(venue.chain()),
});
cex.chain(dex).collect()
}
pub fn ids(registry: &VenueRegistry) -> Vec<String> {
Self::listings(registry)
.into_iter()
.map(|listing| listing.id)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dex_names_parse_in_any_case() {
for (input, venue) in [
("eth", DexVenue::Ethereum),
("ethereum", DexVenue::Ethereum),
("Ethereum", DexVenue::Ethereum),
("solana", DexVenue::Solana),
("Solana", DexVenue::Solana),
] {
assert_eq!(DexVenue::parse(input), Some(venue), "{input}");
}
}
#[test]
fn sol_is_the_solana_dex() {
assert_eq!(DexVenue::parse("sol"), Some(DexVenue::Solana));
assert_eq!(DexVenue::parse("SOL"), Some(DexVenue::Solana));
assert_eq!(DexVenue::Solana.chain(), "solana");
}
#[test]
fn other_names_are_not_dex_venues() {
for input in ["binance", "okx", "mexc", "kraken", "polygon", "unknown", ""] {
assert_eq!(DexVenue::parse(input), None, "{input}");
}
}
#[test]
fn chain_check_accepts_any_spelling_of_the_venue_chain() {
assert!(DexVenue::Ethereum.require_chain("ethereum").is_ok());
assert!(DexVenue::Ethereum.require_chain("ETH").is_ok());
assert!(DexVenue::Solana.require_chain("Sol").is_ok());
let err = DexVenue::Solana.require_chain("ethereum").unwrap_err();
assert!(err.to_string().contains("reads chain solana"), "{err}");
}
#[test]
fn resolve_sends_dex_names_to_dex_and_others_to_the_registry() {
let registry = VenueRegistry::load().unwrap();
assert!(matches!(
MarketVenue::resolve_with_http("sol", ®istry, crate::http::test_transport())
.unwrap(),
MarketVenue::Dex(DexVenue::Solana)
));
assert!(matches!(
MarketVenue::resolve_with_http("binance", ®istry, crate::http::test_transport())
.unwrap(),
MarketVenue::Cex(_)
));
}
#[test]
fn an_unknown_venue_is_an_error_that_lists_the_dex_venues() {
let registry = VenueRegistry::load().unwrap();
let err =
MarketVenue::resolve_with_http("polygon", ®istry, crate::http::test_transport())
.err()
.expect("unknown venue must be an error");
let msg = err.to_string();
assert!(msg.contains("Unknown venue \"polygon\""), "{msg}");
assert!(msg.contains(", ethereum, solana\n"), "{msg}");
assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
}
#[test]
fn the_registry_lookup_does_not_suggest_dex_venues() {
let registry = VenueRegistry::load().unwrap();
let err = registry
.create_exchange_client_with_http("solana", crate::http::test_transport())
.err()
.unwrap();
assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
let msg = err.to_string();
assert!(msg.contains("Unknown venue \"solana\""), "{msg}");
assert!(!msg.contains("ethereum"), "{msg}");
}
#[test]
fn a_typo_of_a_dex_venue_is_suggested_where_dex_venues_are_valid() {
let registry = VenueRegistry::load().unwrap();
let err =
MarketVenue::resolve_with_http("solanna", ®istry, crate::http::test_transport())
.err()
.unwrap();
assert!(err.to_string().contains("Did you mean \"solana\""), "{err}");
}
#[test]
fn one_list_holds_the_registry_and_the_dex_venues() {
let registry = VenueRegistry::load().unwrap();
let ids = MarketVenue::ids(®istry);
let cex: Vec<String> = registry.list().into_iter().map(str::to_string).collect();
assert_eq!(ids[..cex.len()], cex[..]);
assert_eq!(ids[cex.len()..], ["ethereum", "solana"]);
let last = MarketVenue::listings(®istry).pop().unwrap();
assert_eq!(last.kind, VenueKind::Dex);
assert_eq!(last.chain, Some("solana"));
}
#[test]
fn a_chain_mismatch_is_a_request_fault() {
let err = DexVenue::Solana.require_chain("ethereum").unwrap_err();
assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
}
#[test]
fn dex_aliases_come_from_chain_metadata() {
for alias in ["eth", "ETH", "sol", " Solana "] {
let venue = DexVenue::parse(alias).unwrap();
let chain = chain_metadata(alias.trim()).unwrap().chain_id;
assert_eq!(chain, venue.chain());
}
assert_eq!(
DexVenue::parse("polygon"),
None,
"a chain without a DEX venue"
);
}
#[test]
fn a_cex_venue_ignores_the_chain() {
let registry = VenueRegistry::load().unwrap();
let venue =
MarketVenue::resolve_with_http("binance", ®istry, crate::http::test_transport())
.unwrap();
assert!(venue.require_chain("solana").is_ok());
}
}