Skip to main content

scope/market/
assets.rs

1//! Which token a venue's symbol is: the asset's networks and contracts,
2//! from the venue's coin list (the `assets` capability).
3//!
4//! A venue symbol such as `USDN` names one asset on that venue, but not
5//! which token it is. The report shows the contract, so a reader can check
6//! it against the issuer's.
7
8use serde::Serialize;
9
10/// One network of an asset and the token contract on it.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct AssetNetwork {
13    /// The venue's network name (e.g. `ETH`).
14    pub network: String,
15    /// The token contract, or `None` for the network's native coin.
16    pub contract: Option<String>,
17}
18
19/// An asset in the venue's coin list.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct AssetInfo {
22    pub symbol: String,
23    pub networks: Vec<AssetNetwork>,
24}
25
26/// Venue network codes that are not chain names or aliases in
27/// `chains::registry` (which already knows eth, bnb, matic, arb, op, sol,
28/// trx).
29const NETWORK_CODES: [(&str, &str); 5] = [
30    ("erc20", "ethereum"),
31    ("bep20", "bsc"),
32    ("trc20", "tron"),
33    // Avalanche's C-Chain, as venues name it (#108: a saved AVAXC binding
34    // was never found by `--chain avalanche`).
35    ("avaxc", "avalanche"),
36    ("avax-c", "avalanche"),
37];
38
39/// The scope chain for a venue's network name (`ETH`, `ERC20`, `BSC`,
40/// `TRX`, …). An unknown network is its own name in lower case.
41pub fn chain_for_network(network: &str) -> String {
42    let n = network.trim().to_ascii_lowercase();
43    if let Some(chain) = crate::chains::registry::chain(&n) {
44        return chain.id.to_string();
45    }
46    NETWORK_CODES
47        .iter()
48        .find(|(code, _)| *code == n)
49        .map(|(_, chain)| chain.to_string())
50        .unwrap_or(n)
51}
52
53/// The line the report shows for one token of the pair.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct TokenAddress {
56    pub symbol: String,
57    pub network: String,
58    pub contract: Option<String>,
59    /// Networks the venue lists for the asset that this line does not show.
60    pub other_networks: usize,
61}
62
63/// The shortest string read as a contract. Venues put a placeholder such
64/// as `btc` in the contract field of a native coin.
65const MIN_CONTRACT_LEN: usize = 20;
66
67/// The contract from a venue's contract field, or `None` for a native coin.
68pub fn contract_of(raw: &str) -> Option<String> {
69    let raw = raw.trim();
70    (raw.len() >= MIN_CONTRACT_LEN).then(|| raw.to_string())
71}
72
73/// The lines for the pair's tokens. The base shows every network the venue
74/// lists. The quote (often listed on many networks) shows the network it
75/// shares with the base, else its first, and counts the others. A symbol
76/// missing from the coin list has no line.
77pub fn token_addresses(base: &str, quote: &str, assets: &[AssetInfo]) -> Vec<TokenAddress> {
78    let find = |s: &str| assets.iter().find(|a| a.symbol.eq_ignore_ascii_case(s));
79    let mut lines = Vec::new();
80    let mut base_networks = Vec::new();
81    if let Some(b) = find(base) {
82        for n in &b.networks {
83            base_networks.push(n.network.clone());
84            lines.push(TokenAddress {
85                symbol: b.symbol.clone(),
86                network: n.network.clone(),
87                contract: n.contract.clone(),
88                other_networks: 0,
89            });
90        }
91    }
92    if let Some(q) = find(quote)
93        && let Some(n) = q
94            .networks
95            .iter()
96            .find(|n| base_networks.contains(&n.network))
97            .or_else(|| q.networks.first())
98    {
99        lines.push(TokenAddress {
100            symbol: q.symbol.clone(),
101            network: n.network.clone(),
102            contract: n.contract.clone(),
103            other_networks: q.networks.len() - 1,
104        });
105    }
106    lines
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn asset(symbol: &str, networks: &[(&str, &str)]) -> AssetInfo {
114        AssetInfo {
115            symbol: symbol.to_string(),
116            networks: networks
117                .iter()
118                .map(|(n, c)| AssetNetwork {
119                    network: n.to_string(),
120                    contract: contract_of(c),
121                })
122                .collect(),
123        }
124    }
125
126    const USDN: &str = "0xeC747DAf41A4Ca4ebE72356592F8301924132907";
127    const USDT_ETH: &str = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
128
129    #[test]
130    fn test_the_quote_shows_the_network_it_shares_with_the_base() {
131        // USDT is on many networks; the line a reader needs is the one on
132        // the base token's network, with the rest counted.
133        let assets = [
134            asset("USDN", &[("ETH", USDN)]),
135            asset(
136                "USDT",
137                &[
138                    ("TRX", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
139                    ("ETH", USDT_ETH),
140                    ("BSC", "0x55d398326f99059fF775485246999027B3197955"),
141                ],
142            ),
143        ];
144        let lines = token_addresses("usdn", "USDT", &assets);
145        assert_eq!(lines.len(), 2);
146        assert_eq!(
147            (lines[0].symbol.as_str(), lines[0].network.as_str()),
148            ("USDN", "ETH")
149        );
150        assert_eq!(lines[0].contract.as_deref(), Some(USDN));
151        assert_eq!(lines[1].network, "ETH");
152        assert_eq!(lines[1].contract.as_deref(), Some(USDT_ETH));
153        assert_eq!(lines[1].other_networks, 2);
154    }
155
156    #[test]
157    fn test_venue_network_codes_map_to_scope_chains() {
158        // Review focus 2: a binding saved as `ethereum` must meet a venue's
159        // `ETH` or `ERC20` as the same chain.
160        for (code, chain) in [
161            ("ETH", "ethereum"),
162            ("ERC20", "ethereum"),
163            ("ethereum", "ethereum"),
164            ("BSC", "bsc"),
165            ("BEP20", "bsc"),
166            ("TRX", "tron"),
167            ("TRC20", "tron"),
168            ("SOL", "solana"),
169            ("AVAXC", "avalanche"),
170            ("AVAX-C", "avalanche"),
171        ] {
172            assert_eq!(chain_for_network(code), chain, "{code}");
173        }
174        assert_eq!(chain_for_network("SOMECHAIN"), "somechain");
175    }
176
177    #[test]
178    fn test_unlisted_symbols_and_native_coins() {
179        let assets = [
180            asset("BTC", &[("BTC", "btc")]),
181            asset("USDT", &[("TRX", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")]),
182        ];
183        let lines = token_addresses("BTC", "USDT", &assets);
184        // A placeholder such as "btc" is a native coin, not a contract.
185        assert_eq!(lines[0].contract, None);
186        // No shared network: the quote's first network.
187        assert_eq!(lines[1].network, "TRX");
188        assert!(token_addresses("NOPE", "ALSO", &assets).is_empty());
189    }
190}