1use crate::error::{Result, ScopeError};
14use crate::http::{HttpClient, Request};
15use crate::market::descriptor::VenueDescriptor;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::path::{Path, PathBuf};
19
20pub const DEFAULT_CATALOG_URL: &str = "https://raw.githubusercontent.com/robot-accomplice/scope-blockchain-analysis/main/crates/scope-core/venues";
23
24pub const INDEX_FILE: &str = "index.json";
26
27pub const SCHEMA: u32 = 1;
29
30pub const EXAMPLE_FILE: &str = "example.yaml.sample";
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Index {
36 pub schema: u32,
37 pub venues: Vec<Entry>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Entry {
43 pub id: String,
44 pub file: String,
45 pub sha256: String,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct SyncReport {
51 pub added: Vec<String>,
52 pub updated: Vec<String>,
53 pub removed: Vec<String>,
54 pub unchanged: usize,
55}
56
57impl SyncReport {
58 pub fn summary(&self) -> String {
60 let total = self.added.len() + self.updated.len() + self.unchanged;
61 let list = |ids: &[String]| {
62 if ids.is_empty() {
63 String::new()
64 } else {
65 format!(" ({})", ids.join(", "))
66 }
67 };
68 format!(
69 "{total} venues: {} added{}, {} updated{}, {} removed{}",
70 self.added.len(),
71 list(&self.added),
72 self.updated.len(),
73 list(&self.updated),
74 self.removed.len(),
75 list(&self.removed)
76 )
77 }
78}
79
80pub fn checksum(text: &str) -> String {
82 hex::encode(Sha256::digest(text.replace("\r\n", "\n").as_bytes()))
83}
84
85pub fn build_index(dir: &Path) -> Result<Index> {
88 let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
89 .map_err(|e| ScopeError::io_context(e, format!("Cannot read {}", dir.display())))?
90 .flatten()
91 .map(|e| e.path())
92 .filter(|p| p.extension().is_some_and(|ext| ext == "yaml"))
93 .collect();
94 files.sort();
95 let mut venues = Vec::new();
96 for path in files {
97 let text = std::fs::read_to_string(&path)
98 .map_err(|e| ScopeError::io_context(e, format!("Cannot read {}", path.display())))?;
99 let desc = parse(&text, &path.display().to_string())?;
100 venues.push(Entry {
101 id: desc.id,
102 file: path.file_name().unwrap().to_string_lossy().into_owned(),
103 sha256: checksum(&text),
104 });
105 }
106 Ok(Index {
107 schema: SCHEMA,
108 venues,
109 })
110}
111
112fn parse(text: &str, what: &str) -> Result<VenueDescriptor> {
114 serde_saphyr::from_str(text)
115 .map_err(|e| ScopeError::InvalidInput(format!("Venue file {what} does not parse: {e}")))
116}
117
118fn safe_file_name(file: &str) -> bool {
121 file.ends_with(".yaml")
122 && !file.starts_with('.')
123 && file
124 .chars()
125 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
126}
127
128async fn fetch(http: &dyn HttpClient, url: &str) -> Result<String> {
130 let response = http.send(Request::get(url)).await?;
131 if !response.is_success() {
132 return Err(ScopeError::Network(format!(
133 "{url} answered HTTP {}",
134 response.status_code
135 )));
136 }
137 Ok(response.body)
138}
139
140pub async fn sync(http: &dyn HttpClient, base_url: &str, dest: &Path) -> Result<SyncReport> {
144 let base = base_url.trim_end_matches('/');
145 let index: Index = serde_json::from_str(&fetch(http, &format!("{base}/{INDEX_FILE}")).await?)
146 .map_err(|e| {
147 ScopeError::InvalidInput(format!("The venue catalog index does not parse: {e}"))
148 })?;
149 if index.schema > SCHEMA {
150 return Err(ScopeError::InvalidInput(format!(
151 "The venue catalog needs a newer scope (catalog schema {}, this scope reads {SCHEMA}). Update scope, then run `scope venues update` again.",
152 index.schema
153 )));
154 }
155
156 let mut files = Vec::with_capacity(index.venues.len());
157 for entry in &index.venues {
158 if !safe_file_name(&entry.file) {
159 return Err(ScopeError::InvalidInput(format!(
160 "The venue catalog names an unsafe file: {:?}",
161 entry.file
162 )));
163 }
164 let text = fetch(http, &format!("{base}/{}", entry.file)).await?;
165 let got = checksum(&text);
166 if got != entry.sha256 {
167 return Err(ScopeError::InvalidInput(format!(
168 "Venue file {} does not match the catalog index (sha256 {got}, expected {}); the catalog was not changed.",
169 entry.file, entry.sha256
170 )));
171 }
172 let desc = parse(&text, &entry.file)?;
173 if desc.id != entry.id {
174 return Err(ScopeError::InvalidInput(format!(
175 "Venue file {} has id {:?}; the catalog index says {:?}.",
176 entry.file, desc.id, entry.id
177 )));
178 }
179 files.push((entry, text));
180 }
181
182 let old = installed(dest);
183 let parent = dest.parent().unwrap_or(dest);
184 std::fs::create_dir_all(parent)
185 .map_err(|e| ScopeError::io_context(e, format!("Cannot create {}", parent.display())))?;
186 let staging = parent.join("catalog.new");
187 let _ = std::fs::remove_dir_all(&staging);
188 std::fs::create_dir_all(&staging)
189 .map_err(|e| ScopeError::io_context(e, format!("Cannot create {}", staging.display())))?;
190 for (entry, text) in &files {
191 let path = staging.join(&entry.file);
192 std::fs::write(&path, text)
193 .map_err(|e| ScopeError::io_context(e, format!("Cannot write {}", path.display())))?;
194 }
195 let index_json = serde_json::to_string_pretty(&index).unwrap_or_default();
196 std::fs::write(staging.join(INDEX_FILE), index_json + "\n")
197 .map_err(|e| ScopeError::io_context(e, "Cannot write the catalog index".to_string()))?;
198 let retired = parent.join("catalog.old");
199 let _ = std::fs::remove_dir_all(&retired);
200 if dest.exists() {
201 std::fs::rename(dest, &retired)
202 .map_err(|e| ScopeError::io_context(e, format!("Cannot replace {}", dest.display())))?;
203 }
204 if let Err(e) = std::fs::rename(&staging, dest) {
205 let _ = std::fs::rename(&retired, dest);
207 return Err(ScopeError::io_context(
208 e,
209 format!("Cannot install the catalog in {}", dest.display()),
210 ));
211 }
212 let _ = std::fs::remove_dir_all(&retired);
213
214 let mut report = SyncReport::default();
215 for entry in &index.venues {
216 match old.iter().find(|o| o.id == entry.id) {
217 None => report.added.push(entry.id.clone()),
218 Some(o) if o.sha256 != entry.sha256 => report.updated.push(entry.id.clone()),
219 Some(_) => report.unchanged += 1,
220 }
221 }
222 report.removed = old
223 .iter()
224 .filter(|o| !index.venues.iter().any(|e| e.id == o.id))
225 .map(|o| o.id.clone())
226 .collect();
227 Ok(report)
228}
229
230pub fn installed(dir: &Path) -> Vec<Entry> {
232 std::fs::read_to_string(dir.join(INDEX_FILE))
233 .ok()
234 .and_then(|text| serde_json::from_str::<Index>(&text).ok())
235 .map(|index| index.venues)
236 .unwrap_or_default()
237}
238
239pub fn example_path() -> PathBuf {
241 crate::paths::venues_dir().join(EXAMPLE_FILE)
242}
243
244pub fn write_example(path: &Path) -> std::io::Result<()> {
246 if path.exists() {
247 return Ok(());
248 }
249 if let Some(dir) = path.parent() {
250 std::fs::create_dir_all(dir)?;
251 }
252 std::fs::write(path, EXAMPLE)
253}
254
255pub const EXAMPLE: &str = r#"# How to add an exchange to scope by hand
258# =========================================
259#
260# Copy this file to a name ending in .yaml in this folder (for example
261# myexchange.yaml), fill it in, and check it with:
262#
263# scope venues validate ~/.scope-bca/venues/myexchange.yaml
264#
265# Files in this folder override a catalog venue with the same `id`. The
266# catalog itself lives in the `catalog/` folder and is refreshed by
267# `scope venues update`; do not edit it, your changes there are replaced.
268#
269# Every path below is the exchange's public REST API: no keys are needed.
270# `{pair}` is the venue's symbol for the pair (see `symbol`), `{limit}` the
271# number of items asked for, `{interval}` a candle interval such as 1h.
272
273# A short, lowercase name: what `--venue` takes.
274id: myexchange
275# The name shown in listings.
276name: My Exchange Spot
277# The API's root URL, without a trailing slash.
278base_url: https://api.myexchange.example
279timeout_secs: 15
280rate_limit_per_sec: 10
281
282# How the pair is written in the exchange's URLs: BTC/USDT becomes BTCUSDT
283# with "{base}{quote}", BTC_USDT with "{base}_{quote}", BTC-USDT with
284# "{base}-{quote}". `default_quote` is used when only the base is given.
285symbol:
286 template: "{base}{quote}"
287 default_quote: USDT
288
289capabilities:
290 # The order book. `level_format: positional` means each level is
291 # ["price", "quantity"]; use `object` with `level_price` and
292 # `level_size` when levels are {"price": ..., "size": ...}.
293 order_book:
294 path: /api/v3/depth
295 params:
296 symbol: "{pair}"
297 limit: "100"
298 response:
299 asks_key: asks
300 bids_key: bids
301 level_format: positional
302
303 # The 24-hour ticker: map each field to the key the exchange uses.
304 ticker:
305 path: /api/v3/ticker/24hr
306 params:
307 symbol: "{pair}"
308 response:
309 last_price: lastPrice
310 high_24h: highPrice
311 low_24h: lowPrice
312 volume_24h: volume
313 quote_volume_24h: quoteVolume
314 best_bid: bidPrice
315 best_ask: askPrice
316
317 # Recent trades, for the bot-pattern advisory. `side` maps the
318 # exchange's value to buy or sell.
319 trades:
320 path: /api/v3/trades
321 params:
322 symbol: "{pair}"
323 limit: "{limit}"
324 response:
325 price: price
326 quantity: qty
327 quote_quantity: quoteQty
328 timestamp_ms: time
329 id: id
330 side:
331 field: isBuyerMaker
332 mapping:
333 "true": sell
334 "false": buy
335
336 # Candles, for `scope market ohlc`. `array_of_arrays` reads rows such as
337 # [open_time, open, high, low, close, volume, close_time].
338 ohlc:
339 path: /api/v3/klines
340 params:
341 symbol: "{pair}"
342 interval: "{interval}"
343 limit: "{limit}"
344 response:
345 ohlc_format: array_of_arrays
346 ohlc_fields: [open_time, open, high, low, close, volume, close_time]
347
348# The venues in catalog/ are complete, working examples of other API shapes
349# (nested data keys, object levels, other symbol formats): copy the one
350# closest to your exchange.
351"#;
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::http::Response;
357 use std::sync::Mutex;
358
359 fn repo() -> PathBuf {
361 Path::new(env!("CARGO_MANIFEST_DIR")).join("venues")
362 }
363
364 #[test]
365 fn test_the_repo_index_matches_the_venue_files() {
366 let built = build_index(&repo()).unwrap();
369 let path = repo().join(INDEX_FILE);
370 if std::env::var_os("SCOPE_UPDATE_VENUE_INDEX").is_some() {
371 let json = serde_json::to_string_pretty(&built).unwrap() + "\n";
372 std::fs::write(&path, json).unwrap();
373 }
374 let on_disk: Index =
375 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
376 assert_eq!(
377 on_disk, built,
378 "crates/scope-core/venues/index.json is stale; run with SCOPE_UPDATE_VENUE_INDEX=1 to rewrite it"
379 );
380 assert!(built.venues.len() >= 11);
381 }
382
383 #[test]
384 fn test_checksums_ignore_windows_line_ends() {
385 assert_eq!(
386 checksum("id: a\nname: b\n"),
387 checksum("id: a\r\nname: b\r\n")
388 );
389 assert_ne!(checksum("id: a\n"), checksum("id: b\n"));
390 }
391
392 #[test]
393 fn test_the_example_is_a_valid_descriptor_that_is_not_loaded() {
394 let desc: VenueDescriptor = serde_saphyr::from_str(EXAMPLE).unwrap();
395 assert_eq!(desc.id, "myexchange");
396 assert_eq!(desc.capability_names().len(), 4);
397 let dir = tempfile::tempdir().unwrap();
398 let path = dir.path().join(EXAMPLE_FILE);
399 write_example(&path).unwrap();
400 std::fs::write(&path, "mine").unwrap();
402 write_example(&path).unwrap();
403 assert_eq!(std::fs::read_to_string(&path).unwrap(), "mine");
404 let registry =
405 crate::market::VenueRegistry::load_dirs(&dir.path().join("none"), dir.path()).unwrap();
406 assert!(registry.is_empty(), "a .sample file is not a venue");
407 }
408
409 #[test]
410 fn test_file_names_stay_in_the_catalog() {
411 assert!(safe_file_name("weex.yaml"));
412 assert!(safe_file_name("crypto_com.yaml"));
413 for bad in [
414 "../x.yaml",
415 "a/b.yaml",
416 "..\\x.yaml",
417 ".hidden.yaml",
418 "x.yml",
419 "x.sh",
420 ] {
421 assert!(!safe_file_name(bad), "{bad}");
422 }
423 }
424
425 struct Served(Mutex<Vec<(String, u16, String)>>);
427
428 #[async_trait::async_trait]
429 impl HttpClient for Served {
430 async fn send(&self, request: Request) -> std::result::Result<Response, ScopeError> {
431 let routes = self.0.lock().unwrap();
432 let (_, status, body) = routes
433 .iter()
434 .find(|(path, _, _)| request.url.ends_with(path.as_str()))
435 .cloned()
436 .unwrap_or((String::new(), 404, String::new()));
437 Ok(Response {
438 status_code: status,
439 headers: Default::default(),
440 body,
441 })
442 }
443 }
444
445 fn venue(id: &str, name: &str) -> String {
446 format!(
447 "id: {id}\nname: {name}\nbase_url: https://{id}.example\nsymbol:\n template: \"{{base}}{{quote}}\"\n default_quote: USDT\ncapabilities:\n ticker:\n path: /t\n response:\n last_price: p\n"
448 )
449 }
450
451 fn served(venues: &[(&str, &str, &str)]) -> Served {
452 let index = Index {
453 schema: SCHEMA,
454 venues: venues
455 .iter()
456 .map(|(id, file, body)| Entry {
457 id: id.to_string(),
458 file: file.to_string(),
459 sha256: checksum(body),
460 })
461 .collect(),
462 };
463 let mut routes = vec![(
464 format!("/{INDEX_FILE}"),
465 200,
466 serde_json::to_string(&index).unwrap(),
467 )];
468 routes.extend(
469 venues
470 .iter()
471 .map(|(_, file, body)| (format!("/{file}"), 200, body.to_string())),
472 );
473 Served(Mutex::new(routes))
474 }
475
476 #[tokio::test]
477 async fn test_a_sync_installs_reports_and_replaces_the_catalog() {
478 let home = tempfile::tempdir().unwrap();
479 let dest = home.path().join("venues").join("catalog");
480 let a = venue("alpha", "Alpha");
481 let b = venue("beta", "Beta");
482 let report = sync(
483 &served(&[("alpha", "alpha.yaml", &a), ("beta", "beta.yaml", &b)]),
484 "https://x/c",
485 &dest,
486 )
487 .await
488 .unwrap();
489 assert_eq!(report.added, ["alpha", "beta"]);
490 assert_eq!(
491 report.summary(),
492 "2 venues: 2 added (alpha, beta), 0 updated, 0 removed"
493 );
494 let registry =
495 crate::market::VenueRegistry::load_dirs(&dest, &home.path().join("u")).unwrap();
496 assert_eq!(registry.list(), ["alpha", "beta"]);
497
498 let b2 = venue("beta", "Beta 2");
500 let c = venue("gamma", "Gamma");
501 let report = sync(
502 &served(&[("beta", "beta.yaml", &b2), ("gamma", "gamma.yaml", &c)]),
503 "https://x/c/",
504 &dest,
505 )
506 .await
507 .unwrap();
508 assert_eq!(report.added, ["gamma"]);
509 assert_eq!(report.updated, ["beta"]);
510 assert_eq!(report.removed, ["alpha"]);
511 assert!(!dest.join("alpha.yaml").exists());
512 assert!(!home.path().join("venues").join("catalog.new").exists());
513 assert!(!home.path().join("venues").join("catalog.old").exists());
514 }
515
516 #[tokio::test]
517 async fn test_a_bad_file_leaves_the_old_catalog() {
518 let home = tempfile::tempdir().unwrap();
519 let dest = home.path().join("catalog");
520 let a = venue("alpha", "Alpha");
521 sync(&served(&[("alpha", "alpha.yaml", &a)]), "https://x", &dest)
522 .await
523 .unwrap();
524
525 let tampered = served(&[("alpha", "alpha.yaml", &a)]);
527 tampered.0.lock().unwrap()[1].2 = venue("alpha", "Evil");
528 let err = sync(&tampered, "https://x", &dest)
529 .await
530 .unwrap_err()
531 .to_string();
532 assert!(err.contains("does not match the catalog index"), "{err}");
533 let err = sync(&served(&[("beta", "alpha.yaml", &a)]), "https://x", &dest)
535 .await
536 .unwrap_err()
537 .to_string();
538 assert!(err.contains("the catalog index says"), "{err}");
539 let err = sync(
541 &served(&[("alpha", "../alpha.yaml", &a)]),
542 "https://x",
543 &dest,
544 )
545 .await
546 .unwrap_err()
547 .to_string();
548 assert!(err.contains("unsafe file"), "{err}");
549 let newer = served(&[]);
551 newer.0.lock().unwrap()[0].2 = r#"{"schema": 99, "venues": []}"#.to_string();
552 let err = sync(&newer, "https://x", &dest)
553 .await
554 .unwrap_err()
555 .to_string();
556 assert!(err.contains("needs a newer scope"), "{err}");
557 let err = sync(&Served(Mutex::new(vec![])), "https://x", &dest)
559 .await
560 .unwrap_err()
561 .to_string();
562 assert!(err.contains("HTTP 404"), "{err}");
563
564 assert_eq!(installed(&dest).len(), 1);
566 assert_eq!(std::fs::read_to_string(dest.join("alpha.yaml")).unwrap(), a);
567 }
568}