|
| 1 | +use super::*; |
| 2 | + |
| 3 | +pub(super) fn discover_markets( |
| 4 | + client: &RaindexClient, |
| 5 | + options: &MarketListOptions, |
| 6 | +) -> Result<Vec<RaindexMarket>, RaindexError> { |
| 7 | + let selected_chains = options |
| 8 | + .chain_ids |
| 9 | + .as_ref() |
| 10 | + .map(|ids| ids.iter().copied().collect::<HashSet<_>>()); |
| 11 | + let tokens = client.get_all_tokens()?.into_values().collect::<Vec<_>>(); |
| 12 | + let raindexes = client |
| 13 | + .get_all_raindexes()? |
| 14 | + .into_values() |
| 15 | + .collect::<Vec<_>>(); |
| 16 | + let mut bases = BTreeMap::<(u32, Address), TokenCfg>::new(); |
| 17 | + |
| 18 | + tokens |
| 19 | + .iter() |
| 20 | + .filter(|token| { |
| 21 | + selected_chains |
| 22 | + .as_ref() |
| 23 | + .is_none_or(|chains| chains.contains(&token.network.chain_id)) |
| 24 | + && extension_bool(token, MARKET_LISTED_EXTENSION) |
| 25 | + && !extension_bool(token, MARKET_QUOTE_EXTENSION) |
| 26 | + }) |
| 27 | + .for_each(|token| { |
| 28 | + bases.insert((token.network.chain_id, token.address), token.clone()); |
| 29 | + }); |
| 30 | + |
| 31 | + let mut markets = bases |
| 32 | + .into_values() |
| 33 | + .map(|base| { |
| 34 | + let quote = unique_quote_token(&tokens, base.network.chain_id)?; |
| 35 | + let base = market_token(&base); |
| 36 | + let quote = market_token("e); |
| 37 | + let mut raindex_addresses = raindexes |
| 38 | + .iter() |
| 39 | + .filter(|raindex| raindex.network.chain_id == base.chain_id) |
| 40 | + .map(|raindex| raindex.address) |
| 41 | + .collect::<Vec<_>>(); |
| 42 | + raindex_addresses.sort_unstable(); |
| 43 | + raindex_addresses.dedup(); |
| 44 | + let id = format!("{}:{:#x}:{:#x}", base.chain_id, base.address, quote.address); |
| 45 | + let ticker_id = format!("{:#x}_{:#x}", base.address, quote.address); |
| 46 | + Ok(RaindexMarket { |
| 47 | + id, |
| 48 | + ticker_id, |
| 49 | + chain_id: base.chain_id, |
| 50 | + base, |
| 51 | + quote, |
| 52 | + raindex_addresses, |
| 53 | + }) |
| 54 | + }) |
| 55 | + .collect::<Result<Vec<_>, RaindexError>>()?; |
| 56 | + markets.sort_by(|a, b| { |
| 57 | + (a.chain_id, a.base.symbol.as_str(), a.base.address).cmp(&( |
| 58 | + b.chain_id, |
| 59 | + b.base.symbol.as_str(), |
| 60 | + b.base.address, |
| 61 | + )) |
| 62 | + }); |
| 63 | + Ok(markets) |
| 64 | +} |
| 65 | + |
| 66 | +fn unique_quote_token(tokens: &[TokenCfg], chain_id: u32) -> Result<TokenCfg, RaindexError> { |
| 67 | + let quotes = tokens |
| 68 | + .iter() |
| 69 | + .filter(|token| { |
| 70 | + token.network.chain_id == chain_id && extension_bool(token, MARKET_QUOTE_EXTENSION) |
| 71 | + }) |
| 72 | + .fold(BTreeMap::<Address, TokenCfg>::new(), |mut quotes, token| { |
| 73 | + quotes.entry(token.address).or_insert_with(|| token.clone()); |
| 74 | + quotes |
| 75 | + }); |
| 76 | + |
| 77 | + match quotes.into_values().collect::<Vec<_>>().as_slice() { |
| 78 | + [quote] => Ok(quote.clone()), |
| 79 | + [] => Err(RaindexError::PreflightError(format!( |
| 80 | + "no token with {MARKET_QUOTE_EXTENSION}=true is configured for chain {chain_id}" |
| 81 | + ))), |
| 82 | + _ => Err(RaindexError::PreflightError(format!( |
| 83 | + "multiple tokens with {MARKET_QUOTE_EXTENSION}=true are configured for chain {chain_id}" |
| 84 | + ))), |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +fn market_token(token: &TokenCfg) -> RaindexMarketToken { |
| 89 | + let unwrapped_address = extension_address(token, "unwrappedAddress"); |
| 90 | + let legacy_address = extension_address(token, "legacyAddress"); |
| 91 | + let receipt_address = extension_address(token, "receiptAddress"); |
| 92 | + let mut variants = [Some(token.address), unwrapped_address, legacy_address] |
| 93 | + .into_iter() |
| 94 | + .flatten() |
| 95 | + .collect::<Vec<_>>(); |
| 96 | + variants.sort_unstable(); |
| 97 | + variants.dedup(); |
| 98 | + |
| 99 | + RaindexMarketToken { |
| 100 | + chain_id: token.network.chain_id, |
| 101 | + address: token.address, |
| 102 | + name: token.label.clone().unwrap_or_else(|| token.key.clone()), |
| 103 | + symbol: token.symbol.clone().unwrap_or_else(|| token.key.clone()), |
| 104 | + decimals: token.decimals, |
| 105 | + logo_uri: token.logo_uri.as_ref().map(ToString::to_string), |
| 106 | + extensions: token.extensions.clone(), |
| 107 | + unwrapped_address, |
| 108 | + legacy_address, |
| 109 | + receipt_address, |
| 110 | + variants, |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +fn extension_string<'a>(token: &'a TokenCfg, key: &str) -> Option<&'a str> { |
| 115 | + token |
| 116 | + .extensions |
| 117 | + .as_ref() |
| 118 | + .and_then(|extensions| extensions.get(key)) |
| 119 | + .and_then(Value::as_str) |
| 120 | +} |
| 121 | + |
| 122 | +fn extension_bool(token: &TokenCfg, key: &str) -> bool { |
| 123 | + token |
| 124 | + .extensions |
| 125 | + .as_ref() |
| 126 | + .and_then(|extensions| extensions.get(key)) |
| 127 | + .and_then(Value::as_bool) |
| 128 | + .unwrap_or(false) |
| 129 | +} |
| 130 | + |
| 131 | +fn extension_address(token: &TokenCfg, key: &str) -> Option<Address> { |
| 132 | + extension_string(token, key).and_then(|address| address.parse().ok()) |
| 133 | +} |
| 134 | + |
| 135 | +#[cfg(all(test, not(target_family = "wasm")))] |
| 136 | +mod tests { |
| 137 | + use super::*; |
| 138 | + use httpmock::MockServer; |
| 139 | + |
| 140 | + const USDC: &str = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; |
| 141 | + const STOCK: &str = "0xfb5b41acdba20a3230f84be995173cfb98b8d6e7"; |
| 142 | + const UNWRAPPED: &str = "0x7271a3c91bb6070ed09333b84a815949d4f16d14"; |
| 143 | + |
| 144 | + async fn client_with_tokens(tokens: Value) -> RaindexClient { |
| 145 | + let server = MockServer::start_async().await; |
| 146 | + server |
| 147 | + .mock_async(|when, then| { |
| 148 | + when.method("GET").path("/tokens"); |
| 149 | + then.status(200).json_body(serde_json::json!({ |
| 150 | + "name": "Markets", |
| 151 | + "timestamp": "2026-01-01T00:00:00Z", |
| 152 | + "version": { "major": 1, "minor": 0, "patch": 0 }, |
| 153 | + "tokens": tokens |
| 154 | + })); |
| 155 | + }) |
| 156 | + .await; |
| 157 | + let yaml = format!( |
| 158 | + r#" |
| 159 | +version: 6 |
| 160 | +networks: |
| 161 | + base: |
| 162 | + rpcs: |
| 163 | + - http://localhost:8545 |
| 164 | + chain-id: 8453 |
| 165 | +subgraphs: |
| 166 | + base: http://localhost:8080/subgraph |
| 167 | +raindexes: |
| 168 | + base: |
| 169 | + address: 0xe522cb4a5fcb2eb31a52ff41a4653d85a4fd7c9d |
| 170 | + network: base |
| 171 | + subgraph: base |
| 172 | + deployment-block: 1 |
| 173 | +using-tokens-from: |
| 174 | + - {}/tokens |
| 175 | +"#, |
| 176 | + server.base_url() |
| 177 | + ); |
| 178 | + RaindexClient::new(vec![yaml], None, None).await.unwrap() |
| 179 | + } |
| 180 | + |
| 181 | + fn token(address: &str, symbol: &str, extensions: Value) -> Value { |
| 182 | + serde_json::json!({ |
| 183 | + "chainId": 8453, |
| 184 | + "address": address, |
| 185 | + "name": symbol, |
| 186 | + "symbol": symbol, |
| 187 | + "decimals": 18, |
| 188 | + "extensions": extensions |
| 189 | + }) |
| 190 | + } |
| 191 | + |
| 192 | + #[tokio::test] |
| 193 | + async fn discovery_uses_only_registry_market_flags_and_preserves_opaque_extensions() { |
| 194 | + let client = client_with_tokens(serde_json::json!([ |
| 195 | + token( |
| 196 | + USDC, |
| 197 | + "USDC", |
| 198 | + serde_json::json!({ "marketListed": true, "marketQuote": true }) |
| 199 | + ), |
| 200 | + token( |
| 201 | + STOCK, |
| 202 | + "wtNVDA", |
| 203 | + serde_json::json!({ |
| 204 | + "marketListed": true, |
| 205 | + "marketQuote": false, |
| 206 | + "unwrappedAddress": UNWRAPPED, |
| 207 | + "isin": "US67066G1040", |
| 208 | + "tradingViewSymbol": "NASDAQ:NVDA" |
| 209 | + }) |
| 210 | + ), |
| 211 | + token( |
| 212 | + "0x1111111111111111111111111111111111111111", |
| 213 | + "IGNORED", |
| 214 | + serde_json::json!({ "marketQuote": false }) |
| 215 | + ) |
| 216 | + ])) |
| 217 | + .await; |
| 218 | + |
| 219 | + let markets = client.get_markets(None).unwrap(); |
| 220 | + |
| 221 | + assert_eq!(markets.len(), 1); |
| 222 | + assert_eq!(markets[0].base.address.to_string().to_lowercase(), STOCK); |
| 223 | + assert_eq!(markets[0].quote.address.to_string().to_lowercase(), USDC); |
| 224 | + assert_eq!(markets[0].base.variants.len(), 2); |
| 225 | + assert_eq!( |
| 226 | + markets[0] |
| 227 | + .base |
| 228 | + .extensions |
| 229 | + .as_ref() |
| 230 | + .and_then(|extensions| extensions.get("isin")) |
| 231 | + .and_then(Value::as_str), |
| 232 | + Some("US67066G1040") |
| 233 | + ); |
| 234 | + } |
| 235 | + |
| 236 | + #[tokio::test] |
| 237 | + async fn discovery_requires_one_quote_token_for_each_listed_market_chain() { |
| 238 | + let client = client_with_tokens(serde_json::json!([token( |
| 239 | + STOCK, |
| 240 | + "wtNVDA", |
| 241 | + serde_json::json!({ "marketListed": true }) |
| 242 | + )])) |
| 243 | + .await; |
| 244 | + |
| 245 | + let error = client.get_markets(None).unwrap_err().to_string(); |
| 246 | + |
| 247 | + assert!(error.contains("no token with marketQuote=true")); |
| 248 | + } |
| 249 | + |
| 250 | + #[tokio::test] |
| 251 | + async fn discovery_rejects_ambiguous_quote_tokens() { |
| 252 | + let client = client_with_tokens(serde_json::json!([ |
| 253 | + token(STOCK, "wtNVDA", serde_json::json!({ "marketListed": true })), |
| 254 | + token(USDC, "USDC", serde_json::json!({ "marketQuote": true })), |
| 255 | + token( |
| 256 | + "0x2222222222222222222222222222222222222222", |
| 257 | + "USDT", |
| 258 | + serde_json::json!({ "marketQuote": true }) |
| 259 | + ) |
| 260 | + ])) |
| 261 | + .await; |
| 262 | + |
| 263 | + let error = client.get_markets(None).unwrap_err().to_string(); |
| 264 | + |
| 265 | + assert!(error.contains("multiple tokens with marketQuote=true")); |
| 266 | + } |
| 267 | +} |
0 commit comments