Skip to content

Commit 584e02d

Browse files
committed
feat: add registry market statistics
1 parent 0bbd401 commit 584e02d

33 files changed

Lines changed: 2391 additions & 11 deletions

Cargo.lock

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ derive_builder = "0.20.0"
4848
thirtyfour = "0.31.0"
4949
test-context = "0.3.0"
5050
portpicker = "0.1.1"
51-
rain-erc = "0.1.1"
51+
rain-erc = "0.1.5"
5252
rain-math-float = "0.1.7"
5353
rain-error-decoding = "0.1.2"
5454
# Direct dep required: the #[wasm_bindgen] macro emits `::wasm_bindgen` paths, and

crates/common/ARCHITECTURE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ require its `COMMIT_SHA` env var.
3838
prepare batch withdraw calldata; expose WASM‑friendly structs. The `local_db/`
3939
subtree is split into `state.rs` (runtime state, query routing via
4040
`LocalDbState`/`QuerySource`/`SyncReadiness`) and `status.rs` (UI
41-
status‑reporting types).
41+
status‑reporting types). The `markets/` subtree discovers registry-listed
42+
base/quote pairs and assembles normalized order books, trades, and rolling
43+
statistics without application-specific token semantics.
4244
- `dotrain_order` — Parse and validate a DOTRAIN config; compose
4345
scenarios/deployments to Rainlang; fetch authoring metadata and pragma words;
4446
merge additional settings.

crates/common/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ csv = { workspace = true }
4343
chrono = { workspace = true }
4444
futures = { workspace = true }
4545
rain-error-decoding = { workspace = true }
46+
rain-erc = { workspace = true }
4647
rain-interpreter-eval = { workspace = true }
4748
wasm-bindgen = { workspace = true }
4849
wasm-bindgen-utils = { workspace = true }
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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(&quote);
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

Comments
 (0)