use crate::tokens::STABLECOIN_SYMBOLS;
use std::fmt;
pub const FIAT_CURRENCIES: [&str; 4] = ["USD", "EUR", "GBP", "TRY"];
pub const DEFAULT_PAIR: &str = "USDC";
const NOT_JOINED_QUOTES: [&str; 2] = ["LUSD", "GUSD"];
fn quote_currencies() -> impl Iterator<Item = &'static str> {
STABLECOIN_SYMBOLS
.into_iter()
.filter(|s| !NOT_JOINED_QUOTES.contains(s))
.chain(FIAT_CURRENCIES)
}
const MIN_BASE_LEN: usize = 2;
const SEPARATORS: [char; 3] = ['_', '/', '-'];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pair {
pub base: String,
pub quote: Option<String>,
}
impl Pair {
pub fn base_only(base: &str) -> Self {
Self {
base: base.trim().to_string(),
quote: None,
}
}
pub fn parse(input: &str) -> Self {
let p = strip_key(input.trim());
if let Some((base, quote)) = split_on_separator(p).or_else(|| split_on_quote_suffix(p)) {
return Self {
base: base.to_string(),
quote: Some(quote.to_string()),
};
}
Self::base_only(p)
}
}
impl fmt::Display for Pair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.quote {
Some(quote) => write!(f, "{}/{}", self.base, quote),
None => f.write_str(&self.base),
}
}
}
fn strip_key(p: &str) -> &str {
match p.split_once('=') {
Some((key, value))
if !value.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
{
value
}
_ => p,
}
}
fn split_on_separator(p: &str) -> Option<(&str, &str)> {
SEPARATORS.iter().find_map(|&sep| {
p.split_once(sep)
.filter(|(base, quote)| !base.is_empty() && !quote.is_empty())
})
}
fn split_on_quote_suffix(p: &str) -> Option<(&str, &str)> {
let upper = p.to_ascii_uppercase();
let quote = quote_currencies()
.filter(|quote| upper.ends_with(quote))
.max_by_key(|quote| quote.len())?;
let base_len = p.len() - quote.len();
(base_len >= MIN_BASE_LEN).then(|| p.split_at(base_len))
}
#[cfg(test)]
mod tests {
use super::*;
fn parts(input: &str) -> (String, Option<String>) {
let pair = Pair::parse(input);
(pair.base, pair.quote)
}
fn pair(base: &str, quote: Option<&str>) -> (String, Option<String>) {
(base.to_string(), quote.map(str::to_string))
}
#[test]
fn separated_forms_split_on_the_separator() {
assert_eq!(parts("USDT_PUSD"), pair("USDT", Some("PUSD")));
assert_eq!(parts("ETH/USDC"), pair("ETH", Some("USDC")));
assert_eq!(parts("BTC-USDT"), pair("BTC", Some("USDT")));
assert_eq!(parts("DAI_USDC"), pair("DAI", Some("USDC")));
}
#[test]
fn case_is_kept() {
assert_eq!(parts("dai_usdt"), pair("dai", Some("usdt")));
assert_eq!(parts("btcusdt"), pair("btc", Some("usdt")));
}
#[test]
fn concatenated_forms_split_on_a_known_quote() {
assert_eq!(parts("BTCUSDT"), pair("BTC", Some("USDT")));
assert_eq!(parts("ETHUSD"), pair("ETH", Some("USD")));
assert_eq!(parts("ETHUSDC"), pair("ETH", Some("USDC")));
assert_eq!(parts("USDCUSDT"), pair("USDC", Some("USDT")));
assert_eq!(parts("XBTEUR"), pair("XBT", Some("EUR")));
}
#[test]
fn gbp_and_try_parse_and_display() {
assert_eq!(parts("BTCGBP"), pair("BTC", Some("GBP")));
assert_eq!(parts("BTCTRY"), pair("BTC", Some("TRY")));
assert_eq!(Pair::parse("BTCGBP").to_string(), "BTC/GBP");
assert_eq!(Pair::parse("BTCTRY").to_string(), "BTC/TRY");
assert_eq!(Pair::parse("XBTGBP").to_string(), "XBT/GBP");
}
#[test]
fn a_bare_base_has_no_quote() {
assert_eq!(parts("ARB"), pair("ARB", None));
assert_eq!(parts("USDC"), pair("USDC", None));
assert_eq!(parts("USDT"), pair("USDT", None));
assert_eq!(parts("X"), pair("X", None));
assert_eq!(parts("XYZABC"), pair("XYZABC", None));
assert_eq!(parts(""), pair("", None));
assert_eq!(Pair::parse("USDT").to_string(), "USDT");
assert_eq!(Pair::parse("A").to_string(), "A");
}
#[test]
fn whitespace_and_key_value_input_are_accepted() {
assert_eq!(parts(" DAI_USDT "), pair("DAI", Some("USDT")));
assert_eq!(parts("pair=USDT_PUSD"), pair("USDT", Some("PUSD")));
assert_eq!(parts("pair_symbol=USDT_PUSD"), pair("USDT", Some("PUSD")));
}
#[test]
fn venue_symbols_display_with_a_slash() {
assert_eq!(Pair::parse("BTC_USDT").to_string(), "BTC/USDT");
assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
}
#[test]
fn base_only_never_splits_a_token_symbol() {
assert_eq!(
Pair::base_only("WETH"),
Pair {
base: "WETH".into(),
quote: None
}
);
}
#[test]
fn concatenated_input_splits_only_on_a_stable_or_fiat_quote() {
assert_eq!(parts("OPUSDT"), pair("OP", Some("USDT")));
assert_eq!(parts("WBTCUSDT"), pair("WBTC", Some("USDT")));
assert_eq!(parts("ETHFDUSD"), pair("ETH", Some("FDUSD")));
assert_eq!(parts("BTCBUSD"), pair("BTC", Some("BUSD")));
assert_eq!(parts("ETHDAI"), pair("ETH", Some("DAI")));
assert_eq!(parts("WETH"), pair("WETH", None));
assert_eq!(parts("STETH"), pair("STETH", None));
assert_eq!(parts("BTCETH"), pair("BTCETH", None));
assert_eq!(parts("BUSD"), pair("BUSD", None));
assert_eq!(parts("FDUSD"), pair("FDUSD", None));
assert_eq!(parts("TUSD"), pair("TUSD", None));
assert_eq!(parts("XUSDT"), pair("XUSDT", None));
}
#[test]
fn every_stablecoin_is_a_quote_and_counts_as_usd() {
assert_eq!(parts("BTCTUSD"), pair("BTC", Some("TUSD")));
assert_eq!(parts("ETHUSDP"), pair("ETH", Some("USDP")));
for symbol in STABLECOIN_SYMBOLS
.into_iter()
.filter(|s| !NOT_JOINED_QUOTES.contains(s))
{
let quote = Pair::parse(&format!("BTC{symbol}")).quote;
assert_eq!(quote.as_deref(), Some(symbol), "{symbol}");
}
let quote = Pair::parse("BTC/FDUSD").quote.unwrap();
assert!(crate::tokens::is_stablecoin_symbol("e));
}
#[test]
fn a_joined_symbol_never_ends_in_a_non_quote_stablecoin() {
assert_eq!(parts("SOLUSD"), pair("SOL", Some("USD")));
assert_eq!(parts("DOGUSD"), pair("DOG", Some("USD")));
assert_eq!(parts("BTCLUSD"), pair("BTCL", Some("USD")));
assert_eq!(parts("SOL/LUSD"), pair("SOL", Some("LUSD")));
for symbol in NOT_JOINED_QUOTES {
assert!(crate::tokens::is_stablecoin_symbol(symbol), "{symbol}");
}
}
#[test]
fn a_separator_always_splits() {
assert_eq!(parts("BTC_ETH"), pair("BTC", Some("ETH")));
assert_eq!(parts("WETH/BTC"), pair("WETH", Some("BTC")));
assert_eq!(parts("X-USDT"), pair("X", Some("USDT")));
}
}