1use crate::tokens::STABLECOIN_SYMBOLS;
9use std::fmt;
10
11pub const FIAT_CURRENCIES: [&str; 4] = ["USD", "EUR", "GBP", "TRY"];
16
17pub const DEFAULT_PAIR: &str = "USDC";
19
20const NOT_JOINED_QUOTES: [&str; 2] = ["LUSD", "GUSD"];
24
25fn quote_currencies() -> impl Iterator<Item = &'static str> {
28 STABLECOIN_SYMBOLS
29 .into_iter()
30 .filter(|s| !NOT_JOINED_QUOTES.contains(s))
31 .chain(FIAT_CURRENCIES)
32}
33
34const MIN_BASE_LEN: usize = 2;
37
38const SEPARATORS: [char; 3] = ['_', '/', '-'];
40
41#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Pair {
45 pub base: String,
47 pub quote: Option<String>,
49}
50
51impl Pair {
52 pub fn base_only(base: &str) -> Self {
55 Self {
56 base: base.trim().to_string(),
57 quote: None,
58 }
59 }
60
61 pub fn parse(input: &str) -> Self {
65 let p = strip_key(input.trim());
66 if let Some((base, quote)) = split_on_separator(p).or_else(|| split_on_quote_suffix(p)) {
67 return Self {
68 base: base.to_string(),
69 quote: Some(quote.to_string()),
70 };
71 }
72 Self::base_only(p)
73 }
74}
75
76impl fmt::Display for Pair {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match &self.quote {
79 Some(quote) => write!(f, "{}/{}", self.base, quote),
80 None => f.write_str(&self.base),
81 }
82 }
83}
84
85fn strip_key(p: &str) -> &str {
87 match p.split_once('=') {
88 Some((key, value))
89 if !value.is_empty()
90 && key
91 .chars()
92 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
93 {
94 value
95 }
96 _ => p,
97 }
98}
99
100fn split_on_separator(p: &str) -> Option<(&str, &str)> {
101 SEPARATORS.iter().find_map(|&sep| {
102 p.split_once(sep)
103 .filter(|(base, quote)| !base.is_empty() && !quote.is_empty())
104 })
105}
106
107fn split_on_quote_suffix(p: &str) -> Option<(&str, &str)> {
111 let upper = p.to_ascii_uppercase();
112 let quote = quote_currencies()
113 .filter(|quote| upper.ends_with(quote))
114 .max_by_key(|quote| quote.len())?;
115 let base_len = p.len() - quote.len();
116 (base_len >= MIN_BASE_LEN).then(|| p.split_at(base_len))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn parts(input: &str) -> (String, Option<String>) {
124 let pair = Pair::parse(input);
125 (pair.base, pair.quote)
126 }
127
128 fn pair(base: &str, quote: Option<&str>) -> (String, Option<String>) {
129 (base.to_string(), quote.map(str::to_string))
130 }
131
132 #[test]
133 fn separated_forms_split_on_the_separator() {
134 assert_eq!(parts("USDT_PUSD"), pair("USDT", Some("PUSD")));
135 assert_eq!(parts("ETH/USDC"), pair("ETH", Some("USDC")));
136 assert_eq!(parts("BTC-USDT"), pair("BTC", Some("USDT")));
137 assert_eq!(parts("DAI_USDC"), pair("DAI", Some("USDC")));
138 }
139
140 #[test]
141 fn case_is_kept() {
142 assert_eq!(parts("dai_usdt"), pair("dai", Some("usdt")));
143 assert_eq!(parts("btcusdt"), pair("btc", Some("usdt")));
144 }
145
146 #[test]
147 fn concatenated_forms_split_on_a_known_quote() {
148 assert_eq!(parts("BTCUSDT"), pair("BTC", Some("USDT")));
149 assert_eq!(parts("ETHUSD"), pair("ETH", Some("USD")));
150 assert_eq!(parts("ETHUSDC"), pair("ETH", Some("USDC")));
151 assert_eq!(parts("USDCUSDT"), pair("USDC", Some("USDT")));
152 assert_eq!(parts("XBTEUR"), pair("XBT", Some("EUR")));
153 }
154
155 #[test]
158 fn gbp_and_try_parse_and_display() {
159 assert_eq!(parts("BTCGBP"), pair("BTC", Some("GBP")));
160 assert_eq!(parts("BTCTRY"), pair("BTC", Some("TRY")));
161 assert_eq!(Pair::parse("BTCGBP").to_string(), "BTC/GBP");
162 assert_eq!(Pair::parse("BTCTRY").to_string(), "BTC/TRY");
163 assert_eq!(Pair::parse("XBTGBP").to_string(), "XBT/GBP");
164 }
165
166 #[test]
167 fn a_bare_base_has_no_quote() {
168 assert_eq!(parts("ARB"), pair("ARB", None));
169 assert_eq!(parts("USDC"), pair("USDC", None));
170 assert_eq!(parts("USDT"), pair("USDT", None));
171 assert_eq!(parts("X"), pair("X", None));
172 assert_eq!(parts("XYZABC"), pair("XYZABC", None));
173 assert_eq!(parts(""), pair("", None));
174 assert_eq!(Pair::parse("USDT").to_string(), "USDT");
175 assert_eq!(Pair::parse("A").to_string(), "A");
176 }
177
178 #[test]
179 fn whitespace_and_key_value_input_are_accepted() {
180 assert_eq!(parts(" DAI_USDT "), pair("DAI", Some("USDT")));
181 assert_eq!(parts("pair=USDT_PUSD"), pair("USDT", Some("PUSD")));
182 assert_eq!(parts("pair_symbol=USDT_PUSD"), pair("USDT", Some("PUSD")));
183 }
184
185 #[test]
186 fn venue_symbols_display_with_a_slash() {
187 assert_eq!(Pair::parse("BTC_USDT").to_string(), "BTC/USDT");
188 assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
189 assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
190 assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
191 }
192
193 #[test]
194 fn base_only_never_splits_a_token_symbol() {
195 assert_eq!(
196 Pair::base_only("WETH"),
197 Pair {
198 base: "WETH".into(),
199 quote: None
200 }
201 );
202 }
203
204 #[test]
207 fn concatenated_input_splits_only_on_a_stable_or_fiat_quote() {
208 assert_eq!(parts("OPUSDT"), pair("OP", Some("USDT")));
209 assert_eq!(parts("WBTCUSDT"), pair("WBTC", Some("USDT")));
210 assert_eq!(parts("ETHFDUSD"), pair("ETH", Some("FDUSD")));
211 assert_eq!(parts("BTCBUSD"), pair("BTC", Some("BUSD")));
212 assert_eq!(parts("ETHDAI"), pair("ETH", Some("DAI")));
213 assert_eq!(parts("WETH"), pair("WETH", None));
214 assert_eq!(parts("STETH"), pair("STETH", None));
215 assert_eq!(parts("BTCETH"), pair("BTCETH", None));
216 assert_eq!(parts("BUSD"), pair("BUSD", None));
217 assert_eq!(parts("FDUSD"), pair("FDUSD", None));
218 assert_eq!(parts("TUSD"), pair("TUSD", None));
219 assert_eq!(parts("XUSDT"), pair("XUSDT", None));
220 }
221
222 #[test]
225 fn every_stablecoin_is_a_quote_and_counts_as_usd() {
226 assert_eq!(parts("BTCTUSD"), pair("BTC", Some("TUSD")));
227 assert_eq!(parts("ETHUSDP"), pair("ETH", Some("USDP")));
228 for symbol in STABLECOIN_SYMBOLS
229 .into_iter()
230 .filter(|s| !NOT_JOINED_QUOTES.contains(s))
231 {
232 let quote = Pair::parse(&format!("BTC{symbol}")).quote;
233 assert_eq!(quote.as_deref(), Some(symbol), "{symbol}");
234 }
235 let quote = Pair::parse("BTC/FDUSD").quote.unwrap();
238 assert!(crate::tokens::is_stablecoin_symbol("e));
239 }
240
241 #[test]
245 fn a_joined_symbol_never_ends_in_a_non_quote_stablecoin() {
246 assert_eq!(parts("SOLUSD"), pair("SOL", Some("USD")));
247 assert_eq!(parts("DOGUSD"), pair("DOG", Some("USD")));
248 assert_eq!(parts("BTCLUSD"), pair("BTCL", Some("USD")));
249 assert_eq!(parts("SOL/LUSD"), pair("SOL", Some("LUSD")));
250 for symbol in NOT_JOINED_QUOTES {
251 assert!(crate::tokens::is_stablecoin_symbol(symbol), "{symbol}");
252 }
253 }
254
255 #[test]
256 fn a_separator_always_splits() {
257 assert_eq!(parts("BTC_ETH"), pair("BTC", Some("ETH")));
258 assert_eq!(parts("WETH/BTC"), pair("WETH", Some("BTC")));
259 assert_eq!(parts("X-USDT"), pair("X", Some("USDT")));
260 }
261}