1use crate::cli::output::Output;
9use crate::note;
10use scope::error::{Result, ScopeError};
11use scope::market::assets::chain_for_network;
12use scope::tokens::{TokenAliases, TokenInfo};
13use std::path::Path;
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct Candidate {
18 pub symbol: String,
19 pub name: String,
20 pub chain: String,
22 pub address: Option<String>,
24 pub price_usd: Option<f64>,
25 pub liquidity_usd: Option<f64>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum VenueKind {
31 Dex,
32 Cex(String),
34}
35
36#[derive(Debug, Clone)]
38pub struct ResolveContext {
39 pub chain: Option<String>,
41 pub venue: VenueKind,
42 pub token_flag: Option<String>,
44 pub yes: bool,
46 pub save: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Source {
53 Flag,
54 Address,
55 Binding,
56 Single,
57 Picked {
58 saved: bool,
59 },
60 Unchecked,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct Resolved {
67 pub symbol: String,
68 pub name: String,
69 pub chain: String,
70 pub address: Option<String>,
71 pub source: Source,
72}
73
74#[async_trait::async_trait]
76pub trait CandidateSource: Send + Sync {
77 async fn candidates(&self, symbol: &str, chain: Option<&str>) -> Result<Vec<Candidate>>;
78 fn searched(&self, chain: Option<&str>) -> String;
80 fn lists_contracts(&self) -> bool {
83 true
84 }
85}
86
87#[async_trait::async_trait]
89pub trait Picker: Send + Sync {
90 async fn pick(&self, input: &str, candidates: &[Candidate]) -> Result<usize>;
92 async fn confirm(&self, question: &str) -> Result<bool>;
94}
95
96pub trait BindingStore: Send {
98 fn get(&self, symbol: &str, chain: Option<&str>) -> Option<TokenInfo>;
99 fn all(&self, symbol: &str) -> Vec<TokenInfo>;
101 fn save(&mut self, candidate: &Candidate, venue: Option<&str>) -> Result<()>;
102}
103
104pub fn candidate_line(c: &Candidate) -> String {
106 format!(
107 "{} {} {}",
108 c.chain,
109 c.address.as_deref().unwrap_or("native"),
110 c.name
111 )
112}
113
114fn same_contract(a: &str, b: &str) -> bool {
116 if a.starts_with("0x") && b.starts_with("0x") {
117 a.eq_ignore_ascii_case(b)
118 } else {
119 a == b
120 }
121}
122
123pub(crate) fn dex_candidate(r: &scope::chains::dex::TokenSearchResult) -> Candidate {
126 Candidate {
127 symbol: r.symbol.clone(),
128 name: r.name.clone(),
129 chain: r.chain.clone(),
130 address: (!r.address.is_empty()).then(|| r.address.clone()),
131 price_usd: r.price_usd,
132 liquidity_usd: Some(r.liquidity_usd),
133 }
134}
135
136pub(crate) fn venue_candidates_from(
138 symbol: &str,
139 assets: &[scope::market::assets::AssetInfo],
140) -> Vec<Candidate> {
141 assets
142 .iter()
143 .filter(|a| a.symbol.eq_ignore_ascii_case(symbol))
144 .flat_map(|a| {
145 a.networks.iter().map(|n| Candidate {
146 symbol: a.symbol.clone(),
147 name: a.symbol.clone(),
148 chain: n.network.clone(),
149 address: n.contract.clone(),
150 price_usd: None,
151 liquidity_usd: None,
152 })
153 })
154 .collect()
155}
156
157pub struct DexCandidates<'a> {
160 pub dex: &'a dyn scope::chains::DexDataSource,
161 pub out: &'a Output,
162 pub fallback: Option<std::sync::Arc<dyn scope::http::HttpClient>>,
163}
164
165#[async_trait::async_trait]
166impl CandidateSource for DexCandidates<'_> {
167 async fn candidates(&self, symbol: &str, chain: Option<&str>) -> Result<Vec<Candidate>> {
168 let mut found = self.dex.search_tokens(symbol, chain).await?;
169 if let Some(chain) = chain {
171 found.retain(|r| chain_for_network(&r.chain) == chain_for_network(chain));
172 }
173 if found.is_empty()
174 && let Some(http) = &self.fallback
175 && let Some(f) = crate::cli::token_pick::cex_fallback(
176 symbol,
177 chain.unwrap_or("ethereum"),
178 None,
179 http.clone(),
180 self.out,
181 )
182 .await
183 {
184 found.push(f);
185 }
186 Ok(found.iter().map(dex_candidate).collect())
187 }
188
189 fn searched(&self, chain: Option<&str>) -> String {
190 format!(
191 "DexScreener on {}{}",
192 chain.unwrap_or("all chains"),
193 if self.fallback.is_some() {
194 " and the CEX ticker fallback"
195 } else {
196 ""
197 }
198 )
199 }
200}
201
202pub struct VenueCandidates<'a> {
204 pub client: &'a scope::market::ExchangeClient,
205}
206
207#[async_trait::async_trait]
208impl CandidateSource for VenueCandidates<'_> {
209 async fn candidates(&self, symbol: &str, _chain: Option<&str>) -> Result<Vec<Candidate>> {
210 match self.client.fetch_assets().await {
211 Some(assets) => Ok(venue_candidates_from(symbol, &assets?)),
212 None => Ok(vec![Candidate {
214 symbol: symbol.to_uppercase(),
215 name: symbol.to_uppercase(),
216 chain: String::new(),
217 address: None,
218 price_usd: None,
219 liquidity_usd: None,
220 }]),
221 }
222 }
223
224 fn searched(&self, _chain: Option<&str>) -> String {
225 format!("the {} coin list", self.client.venue_id())
226 }
227
228 fn lists_contracts(&self) -> bool {
229 self.client.lists_assets()
230 }
231}
232
233impl BindingStore for TokenAliases {
234 fn get(&self, symbol: &str, chain: Option<&str>) -> Option<TokenInfo> {
235 TokenAliases::get(self, symbol, chain).cloned()
236 }
237
238 fn all(&self, symbol: &str) -> Vec<TokenInfo> {
239 let mut chains = self.get_chains_for_alias(symbol);
240 chains.sort_unstable();
241 chains
242 .into_iter()
243 .filter_map(|c| TokenAliases::get(self, symbol, Some(c)).cloned())
244 .collect()
245 }
246
247 fn save(&mut self, c: &Candidate, venue: Option<&str>) -> Result<()> {
248 let address = c.address.as_deref().unwrap_or("");
249 self.add_binding(&c.symbol, &c.chain, address, &c.name, venue);
250 TokenAliases::save(self)
251 }
252}
253
254pub struct TerminalPicker<'a> {
256 pub out: &'a Output,
257}
258
259#[async_trait::async_trait]
260impl Picker for TerminalPicker<'_> {
261 async fn pick(&self, _input: &str, cs: &[Candidate]) -> Result<usize> {
262 let table = crate::cli::token_pick::candidate_table(cs);
263 let mut input = crate::cli::prompt::stdin();
264 crate::cli::prompt::pick(&mut input, self.out, &table, "token", cs.len())
265 }
266
267 async fn confirm(&self, question: &str) -> Result<bool> {
268 confirm_from(&mut crate::cli::prompt::stdin(), self.out, question)
269 }
270}
271
272fn confirm_from(reader: &mut impl std::io::BufRead, out: &Output, question: &str) -> Result<bool> {
275 let answer = crate::cli::prompt::ask(reader, out, question)?;
276 Ok(matches!(
277 answer.trim().to_ascii_lowercase().as_str(),
278 "y" | "yes"
279 ))
280}
281
282pub struct TuiPicker {
285 pub asker: crate::cli::output::Asker,
286}
287
288impl TuiPicker {
289 pub(crate) async fn ask(&self, prompt: String, options: Vec<String>) -> Option<usize> {
291 let (reply, answer) = tokio::sync::oneshot::channel();
292 let mut ask = Some(crate::cli::output::Ask {
293 prompt,
294 options,
295 reply,
296 });
297 while ask.is_some() {
300 let placed = {
301 let mut slot = self.asker.lock().unwrap_or_else(|e| e.into_inner());
302 if slot.is_none() {
303 *slot = ask.take();
304 true
305 } else {
306 false
307 }
308 };
309 if !placed {
310 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
314 }
315 }
316 answer.await.ok().flatten()
317 }
318}
319
320#[async_trait::async_trait]
321impl Picker for TuiPicker {
322 async fn pick(&self, input: &str, cs: &[Candidate]) -> Result<usize> {
323 let prompt = format!("'{input}' matches more than one token. Choose one:");
324 self.ask(prompt, cs.iter().map(candidate_line).collect())
325 .await
326 .ok_or_else(|| {
327 ScopeError::Other(format!("Choosing a token for '{input}' was cancelled."))
328 })
329 }
330
331 async fn confirm(&self, question: &str) -> Result<bool> {
332 let prompt = question
334 .trim_end_matches([' ', ':'])
335 .trim_end_matches("[y/N]")
336 .trim_end()
337 .to_string();
338 let options = vec!["No".to_string(), "Yes".to_string()];
339 Ok(self.ask(prompt, options).await == Some(1))
340 }
341}
342
343pub struct NoTerminalPicker;
345
346#[async_trait::async_trait]
347impl Picker for NoTerminalPicker {
348 async fn pick(&self, input: &str, cs: &[Candidate]) -> Result<usize> {
349 Err(ScopeError::AmbiguousToken {
350 input: input.to_string(),
351 candidates: cs.iter().map(candidate_line).collect(),
352 })
353 }
354
355 async fn confirm(&self, _question: &str) -> Result<bool> {
356 Ok(false)
357 }
358}
359
360pub fn picker_for<'a>(out: &'a Output, json: bool) -> Box<dyn Picker + 'a> {
364 use std::io::IsTerminal as _;
365 if json {
366 return Box::new(NoTerminalPicker);
367 }
368 if let Some(asker) = out.asker() {
369 return Box::new(TuiPicker { asker });
370 }
371 let terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
372 if !out.prompts_allowed() || !terminal {
373 Box::new(NoTerminalPicker)
374 } else {
375 Box::new(TerminalPicker { out })
376 }
377}
378
379pub async fn resolve(
381 input: &str,
382 ctx: &ResolveContext,
383 source: &dyn CandidateSource,
384 picker: &dyn Picker,
385 store: &mut dyn BindingStore,
386 out: &Output,
387) -> Result<Resolved> {
388 let log = scope::paths::state_dir().join("resolutions.jsonl");
389 resolve_logged(input, ctx, source, picker, store, out, &log).await
390}
391
392pub async fn resolve_logged(
395 input: &str,
396 ctx: &ResolveContext,
397 source: &dyn CandidateSource,
398 picker: &dyn Picker,
399 store: &mut dyn BindingStore,
400 out: &Output,
401 log: &Path,
402) -> Result<Resolved> {
403 let mut seen = Vec::new();
404 let result = steps(input, ctx, source, picker, store, out, &mut seen).await;
405 if let Err(e) = write_log(log, input, ctx, &seen, &result) {
406 tracing::warn!("cannot write {}: {e}", log.display());
407 }
408 result
409}
410
411async fn steps(
412 input: &str,
413 ctx: &ResolveContext,
414 source: &dyn CandidateSource,
415 picker: &dyn Picker,
416 store: &mut dyn BindingStore,
417 out: &Output,
418 seen: &mut Vec<Candidate>,
419) -> Result<Resolved> {
420 let symbol = input.trim().to_uppercase();
421 let direct = |address: &str, source: Source| Resolved {
424 symbol: symbol.clone(),
425 name: symbol.clone(),
426 chain: ctx.chain.clone().unwrap_or_else(|| {
427 scope::chains::infer_chain_from_address(address)
428 .unwrap_or_default()
429 .to_string()
430 }),
431 address: Some(address.to_string()),
432 source,
433 };
434 if let Some(flag) = &ctx.token_flag {
436 return Ok(direct(flag, Source::Flag));
437 }
438 if TokenAliases::is_address(input.trim()) {
439 return Ok(direct(input.trim(), Source::Address));
440 }
441 let saved = match ctx.chain.as_deref() {
444 None => {
445 let all = store.all(&symbol);
446 if all.len() > 1 {
447 let options: Vec<Candidate> = all.iter().map(binding_candidate).collect();
448 *seen = options.clone();
449 if ctx.yes {
450 return Err(ScopeError::AmbiguousToken {
451 input: input.to_string(),
452 candidates: options.iter().map(candidate_line).collect(),
453 });
454 }
455 Some(all[picker.pick(input, &options).await?].clone())
456 } else {
457 all.into_iter().next()
458 }
459 }
460 Some(chain) => store.get(&symbol, Some(chain)),
461 };
462 if let Some(saved) = saved {
463 if let VenueKind::Cex(venue) = &ctx.venue {
464 check_binding(&symbol, &saved, venue, source, out, seen).await?;
465 }
466 note!(
467 out,
468 "Using your saved {symbol}: {} {}",
469 saved.chain,
470 binding_label(&saved)
471 );
472 let address = binding_contract(&saved).map(str::to_string);
473 return Ok(Resolved {
474 symbol,
475 name: saved.name,
476 chain: saved.chain,
477 address,
478 source: Source::Binding,
479 });
480 }
481 if !source.lists_contracts() {
483 return Ok(Resolved {
484 symbol: symbol.clone(),
485 name: symbol,
486 chain: ctx.chain.clone().unwrap_or_default(),
487 address: None,
488 source: Source::Unchecked,
489 });
490 }
491 let candidates: Vec<Candidate> = source
494 .candidates(&symbol, ctx.chain.as_deref())
495 .await?
496 .into_iter()
497 .filter(|c| {
498 c.symbol.eq_ignore_ascii_case(&symbol)
499 || c.address
500 .as_deref()
501 .is_some_and(|a| a.eq_ignore_ascii_case(input.trim()))
502 })
503 .collect();
504 *seen = candidates.clone();
505 let chosen = match candidates.len() {
507 0 => {
508 return Err(ScopeError::TokenNotFound {
509 input: input.to_string(),
510 searched: source.searched(ctx.chain.as_deref()),
511 });
512 }
513 1 => {
514 note!(out, "Using {}", candidate_line(&candidates[0]));
515 if ctx.save {
516 save_choice(store, &candidates[0], &ctx.venue, &symbol, out);
517 }
518 return Ok(resolved_from(&candidates[0], Source::Single));
519 }
520 _ if ctx.yes => {
521 return Err(ScopeError::AmbiguousToken {
522 input: input.to_string(),
523 candidates: candidates.iter().map(candidate_line).collect(),
524 });
525 }
526 _ => &candidates[picker.pick(input, &candidates).await?],
527 };
528 let chain = chain_for_network(&chosen.chain);
530 let contract = chosen.address.as_deref().unwrap_or(NATIVE_COIN);
531 let save = ctx.save
532 || picker
533 .confirm(&format!(
534 "Save so {symbol} always means {contract} on {chain}? [y/N]: "
535 ))
536 .await?;
537 let saved = save && save_choice(store, chosen, &ctx.venue, &symbol, out);
538 Ok(resolved_from(chosen, Source::Picked { saved }))
539}
540
541fn save_choice(
544 store: &mut dyn BindingStore,
545 chosen: &Candidate,
546 venue: &VenueKind,
547 symbol: &str,
548 out: &Output,
549) -> bool {
550 let mut binding = chosen.clone();
551 binding.chain = chain_for_network(&chosen.chain);
552 let venue = match venue {
553 VenueKind::Cex(v) => Some(v.as_str()),
554 VenueKind::Dex => None,
555 };
556 match store.save(&binding, venue) {
557 Ok(()) => {
558 note!(
559 out,
560 "Saved: {symbol} means {} on {}.",
561 binding.address.as_deref().unwrap_or(NATIVE_COIN),
562 binding.chain
563 );
564 true
565 }
566 Err(e) => {
567 note!(out, "{symbol}: your choice was not saved ({e}).");
568 false
569 }
570 }
571}
572
573fn binding_contract(t: &TokenInfo) -> Option<&str> {
576 (!t.address.is_empty()).then_some(t.address.as_str())
577}
578
579fn binding_label(t: &TokenInfo) -> &str {
581 binding_contract(t).unwrap_or(NATIVE_COIN)
582}
583
584const NATIVE_COIN: &str = "its native coin";
586
587fn binding_candidate(t: &TokenInfo) -> Candidate {
589 Candidate {
590 symbol: t.symbol.clone(),
591 name: t.name.clone(),
592 chain: t.chain.clone(),
593 address: binding_contract(t).map(str::to_string),
594 price_usd: None,
595 liquidity_usd: None,
596 }
597}
598
599async fn check_binding(
603 symbol: &str,
604 saved: &TokenInfo,
605 venue: &str,
606 source: &dyn CandidateSource,
607 out: &Output,
608 seen: &mut Vec<Candidate>,
609) -> Result<()> {
610 if !source.lists_contracts() {
611 note!(
612 out,
613 "{symbol}: {venue} does not list contracts, so your saved token cannot be checked."
614 );
615 return Ok(());
616 }
617 let listed = match source.candidates(symbol, None).await {
618 Ok(listed) => listed,
619 Err(e) => {
620 note!(
621 out,
622 "{symbol}: {venue} could not be checked ({e}); using your saved token."
623 );
624 return Ok(());
625 }
626 };
627 *seen = listed.clone();
628 if listed.is_empty() {
629 return Ok(());
630 }
631 let mismatch = |venue_contract: String| ScopeError::TokenMismatch {
632 symbol: symbol.to_string(),
633 chain: saved.chain.clone(),
634 saved: binding_label(saved).to_string(),
635 venue_contract,
636 venue: venue.to_string(),
637 };
638 match listed
639 .iter()
640 .find(|c| chain_for_network(&c.chain) == saved.chain)
641 {
642 Some(c) => match (&c.address, binding_contract(saved)) {
643 (Some(contract), Some(saved)) if !same_contract(contract, saved) => {
644 Err(mismatch(contract.clone()))
645 }
646 (Some(contract), None) => Err(mismatch(contract.clone())),
648 _ => Ok(()),
649 },
650 None => {
651 let networks: Vec<&str> = listed.iter().map(|c| c.chain.as_str()).collect();
652 Err(mismatch(format!(
653 "no {symbol} on {} (it lists {})",
654 saved.chain,
655 networks.join(", ")
656 )))
657 }
658 }
659}
660
661fn resolved_from(c: &Candidate, source: Source) -> Resolved {
662 Resolved {
663 symbol: c.symbol.to_uppercase(),
664 name: c.name.clone(),
665 chain: chain_for_network(&c.chain),
666 address: c.address.clone(),
667 source,
668 }
669}
670
671fn write_log(
672 log: &Path,
673 input: &str,
674 ctx: &ResolveContext,
675 seen: &[Candidate],
676 result: &Result<Resolved>,
677) -> std::io::Result<()> {
678 use std::io::Write as _;
679 if let Some(dir) = log.parent() {
680 std::fs::create_dir_all(dir)?;
681 }
682 let venue = match &ctx.venue {
683 VenueKind::Dex => "dex".to_string(),
684 VenueKind::Cex(v) => v.clone(),
685 };
686 let mut line = serde_json::json!({
687 "at": chrono::Utc::now().to_rfc3339(),
688 "input": input,
689 "chain": ctx.chain,
690 "venue": venue,
691 "candidates": seen.iter().map(candidate_line).collect::<Vec<_>>(),
692 });
693 match result {
694 Ok(r) => {
695 line["chosen"] = serde_json::json!(r.address);
696 line["source"] = serde_json::json!(match r.source {
697 Source::Flag => "flag",
698 Source::Address => "address",
699 Source::Binding => "binding",
700 Source::Single => "single",
701 Source::Picked { .. } => "picked",
702 Source::Unchecked => "unchecked",
703 });
704 line["saved"] = serde_json::json!(matches!(r.source, Source::Picked { saved: true }));
705 }
706 Err(e) => line["error"] = serde_json::json!(e.to_string()),
707 }
708 let mut f = std::fs::OpenOptions::new()
709 .create(true)
710 .append(true)
711 .open(log)?;
712 writeln!(f, "{line}")
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use std::sync::Mutex;
719
720 fn c(chain: &str, address: &str) -> Candidate {
721 Candidate {
722 symbol: "USDN".into(),
723 name: "USDN".into(),
724 chain: chain.into(),
725 address: Some(address.into()),
726 price_usd: None,
727 liquidity_usd: None,
728 }
729 }
730
731 struct Fake(std::result::Result<Vec<Candidate>, String>);
732 #[async_trait::async_trait]
733 impl CandidateSource for Fake {
734 async fn candidates(&self, _s: &str, _c: Option<&str>) -> Result<Vec<Candidate>> {
735 match &self.0 {
736 Ok(v) => Ok(v.clone()),
737 Err(e) => Err(ScopeError::Network(e.clone())),
738 }
739 }
740 fn searched(&self, _c: Option<&str>) -> String {
741 "the fake source".into()
742 }
743 }
744
745 struct Scripted {
747 pick: Option<usize>,
748 confirm: bool,
749 asked: Mutex<Vec<String>>,
750 }
751 impl Scripted {
752 fn new(pick: Option<usize>, confirm: bool) -> Self {
753 Self {
754 pick,
755 confirm,
756 asked: Mutex::new(Vec::new()),
757 }
758 }
759 fn asked(&self) -> Vec<String> {
760 self.asked.lock().unwrap().clone()
761 }
762 }
763 #[async_trait::async_trait]
764 impl Picker for Scripted {
765 async fn pick(&self, input: &str, cs: &[Candidate]) -> Result<usize> {
766 self.asked
767 .lock()
768 .unwrap()
769 .push(format!("pick {input} {}", cs.len()));
770 self.pick.ok_or_else(|| ScopeError::AmbiguousToken {
771 input: input.into(),
772 candidates: cs.iter().map(candidate_line).collect(),
773 })
774 }
775 async fn confirm(&self, q: &str) -> Result<bool> {
776 self.asked.lock().unwrap().push(format!("confirm {q}"));
777 Ok(self.confirm)
778 }
779 }
780
781 #[derive(Default)]
782 struct Store(Vec<TokenInfo>);
783 impl BindingStore for Store {
784 fn get(&self, s: &str, chain: Option<&str>) -> Option<TokenInfo> {
785 self.0
786 .iter()
787 .find(|t| t.symbol.eq_ignore_ascii_case(s) && chain.is_none_or(|ch| t.chain == ch))
788 .cloned()
789 }
790 fn all(&self, s: &str) -> Vec<TokenInfo> {
791 self.0
792 .iter()
793 .filter(|t| t.symbol.eq_ignore_ascii_case(s))
794 .cloned()
795 .collect()
796 }
797 fn save(&mut self, c: &Candidate, venue: Option<&str>) -> Result<()> {
798 self.0
799 .push(binding(&c.chain, c.address.as_deref().unwrap_or(""), venue));
800 Ok(())
801 }
802 }
803
804 fn binding(chain: &str, address: &str, venue: Option<&str>) -> TokenInfo {
805 TokenInfo {
806 address: address.into(),
807 symbol: "USDN".into(),
808 name: "USDN".into(),
809 chain: chain.into(),
810 last_used: None,
811 venue: venue.map(str::to_string),
812 }
813 }
814
815 fn ctx(venue: VenueKind) -> ResolveContext {
816 ResolveContext {
817 chain: Some("ethereum".into()),
818 venue,
819 token_flag: None,
820 yes: false,
821 save: false,
822 }
823 }
824
825 fn cex() -> VenueKind {
826 VenueKind::Cex("weex".into())
827 }
828
829 fn out() -> Output {
830 Output::capture().0
831 }
832
833 #[tokio::test]
834 async fn test_the_token_flag_wins_over_everything() {
835 let mut store = Store(vec![binding("ethereum", "0xSAVED", None)]);
836 let mut cx = ctx(VenueKind::Dex);
837 cx.token_flag = Some("0xFLAG".into());
838 let picker = Scripted::new(None, false);
839 let r = resolve("USDN", &cx, &Fake(Ok(vec![])), &picker, &mut store, &out())
840 .await
841 .unwrap();
842 assert_eq!(r.address.as_deref(), Some("0xFLAG"));
843 assert!(matches!(r.source, Source::Flag));
844 assert!(picker.asked().is_empty());
845 }
846
847 #[tokio::test]
848 async fn test_an_address_passes_through() {
849 let addr = "0xeC747DAf41A4Ca4ebE72356592F8301924132907";
850 let picker = Scripted::new(None, false);
851 let r = resolve(
852 addr,
853 &ctx(VenueKind::Dex),
854 &Fake(Ok(vec![])),
855 &picker,
856 &mut Store::default(),
857 &out(),
858 )
859 .await
860 .unwrap();
861 assert_eq!(r.address.as_deref(), Some(addr));
862 assert!(matches!(r.source, Source::Address));
863 }
864
865 #[tokio::test]
866 async fn test_a_saved_binding_is_used_without_a_question() {
867 let mut store = Store(vec![binding("ethereum", "0xSAVED", None)]);
868 let picker = Scripted::new(None, false);
869 let two = Fake(Ok(vec![c("ethereum", "0xA"), c("ethereum", "0xB")]));
870 let r = resolve(
871 "usdn",
872 &ctx(VenueKind::Dex),
873 &two,
874 &picker,
875 &mut store,
876 &out(),
877 )
878 .await
879 .unwrap();
880 assert_eq!(r.address.as_deref(), Some("0xSAVED"));
881 assert!(matches!(r.source, Source::Binding));
882 assert!(picker.asked().is_empty());
883 }
884
885 #[tokio::test]
886 async fn test_a_binding_the_venue_contradicts_stops_with_both_contracts() {
887 let mut store = Store(vec![binding("ethereum", "0xAAA", Some("weex"))]);
888 let venue = Fake(Ok(vec![c("ETH", "0xBBB")]));
889 let err = resolve(
890 "USDN",
891 &ctx(cex()),
892 &venue,
893 &Scripted::new(None, false),
894 &mut store,
895 &out(),
896 )
897 .await
898 .unwrap_err();
899 let text = err.to_string();
900 assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{text}");
901 assert!(text.contains("0xAAA") && text.contains("0xBBB"), "{text}");
902 }
903
904 #[tokio::test]
905 async fn test_a_binding_matches_a_venue_network_by_chain() {
906 let mut store = Store(vec![binding("ethereum", "0xAAA", Some("weex"))]);
908 let venue = Fake(Ok(vec![c("BEP20", "0xOTHER"), c("ERC20", "0xAAA")]));
909 let r = resolve(
910 "USDN",
911 &ctx(cex()),
912 &venue,
913 &Scripted::new(None, false),
914 &mut store,
915 &out(),
916 )
917 .await
918 .unwrap();
919 assert_eq!(r.address.as_deref(), Some("0xAAA"));
920 }
921
922 #[tokio::test]
923 async fn test_evm_contracts_compare_without_case() {
924 let mut store = Store(vec![binding(
926 "ethereum",
927 "0xeC747DAf41A4Ca4ebE72356592F8301924132907",
928 None,
929 )]);
930 let venue = Fake(Ok(vec![c(
931 "ETH",
932 "0xec747daf41a4ca4ebe72356592f8301924132907",
933 )]));
934 assert!(
935 resolve(
936 "USDN",
937 &ctx(cex()),
938 &venue,
939 &Scripted::new(None, false),
940 &mut store,
941 &out()
942 )
943 .await
944 .is_ok()
945 );
946 }
947
948 #[tokio::test]
949 async fn test_a_binding_is_used_when_the_venue_check_fails() {
950 let (o, cap) = Output::capture();
952 let mut store = Store(vec![binding("ethereum", "0xAAA", None)]);
953 let venue = Fake(Err("down".into()));
954 let r = resolve(
955 "USDN",
956 &ctx(cex()),
957 &venue,
958 &Scripted::new(None, false),
959 &mut store,
960 &o,
961 )
962 .await
963 .unwrap();
964 assert_eq!(r.address.as_deref(), Some("0xAAA"));
965 assert!(cap.err().contains("could not be checked"), "{}", cap.err());
966 }
967
968 #[tokio::test]
969 async fn test_no_candidates_stops_and_names_the_source() {
970 let err = resolve(
971 "USDN",
972 &ctx(VenueKind::Dex),
973 &Fake(Ok(vec![])),
974 &Scripted::new(None, false),
975 &mut Store::default(),
976 &out(),
977 )
978 .await
979 .unwrap_err();
980 assert!(matches!(err, ScopeError::TokenNotFound { .. }));
981 assert!(err.to_string().contains("the fake source"));
982 }
983
984 #[tokio::test]
985 async fn test_one_candidate_is_used_without_a_question() {
986 let picker = Scripted::new(None, false);
987 let r = resolve(
988 "USDN",
989 &ctx(VenueKind::Dex),
990 &Fake(Ok(vec![c("ethereum", "0xA")])),
991 &picker,
992 &mut Store::default(),
993 &out(),
994 )
995 .await
996 .unwrap();
997 assert_eq!(r.address.as_deref(), Some("0xA"));
998 assert!(matches!(r.source, Source::Single));
999 assert!(picker.asked().is_empty());
1000 }
1001
1002 #[tokio::test]
1003 async fn test_only_exact_symbols_are_candidates() {
1004 let mut other = c("ethereum", "0xX");
1006 other.symbol = "USDNX".into();
1007 let picker = Scripted::new(None, false);
1008 let r = resolve(
1009 "USDN",
1010 &ctx(VenueKind::Dex),
1011 &Fake(Ok(vec![other, c("ethereum", "0xA")])),
1012 &picker,
1013 &mut Store::default(),
1014 &out(),
1015 )
1016 .await
1017 .unwrap();
1018 assert_eq!(r.address.as_deref(), Some("0xA"));
1019 assert!(picker.asked().is_empty(), "one exact match: no question");
1020 }
1021
1022 #[tokio::test]
1023 async fn test_a_pasted_contract_matches_its_candidate() {
1024 let mut t = c("ethereum", "0xTEST123");
1027 t.symbol = "TEST".into();
1028 let r = resolve(
1029 "0xTEST123",
1030 &ctx(VenueKind::Dex),
1031 &Fake(Ok(vec![t])),
1032 &Scripted::new(None, false),
1033 &mut Store::default(),
1034 &out(),
1035 )
1036 .await
1037 .unwrap();
1038 assert_eq!(r.address.as_deref(), Some("0xTEST123"));
1039 }
1040
1041 #[tokio::test]
1042 async fn test_two_candidates_ask_then_save_on_yes() {
1043 let mut store = Store::default();
1044 let picker = Scripted::new(Some(1), true);
1045 let r = resolve(
1046 "USDN",
1047 &ctx(cex()),
1048 &Fake(Ok(vec![c("ETH", "0xA"), c("BSC", "0xB")])),
1049 &picker,
1050 &mut store,
1051 &out(),
1052 )
1053 .await
1054 .unwrap();
1055 assert_eq!(r.address.as_deref(), Some("0xB"));
1056 assert!(matches!(r.source, Source::Picked { saved: true }));
1057 assert_eq!(store.0.len(), 1);
1058 assert_eq!(store.0[0].chain, "bsc", "saved under the scope chain name");
1059 assert_eq!(store.0[0].venue.as_deref(), Some("weex"));
1060 let asked = picker.asked();
1061 assert!(asked[1].contains("always means 0xB on bsc"), "{asked:?}");
1062 }
1063
1064 #[tokio::test]
1065 async fn test_no_saves_nothing_and_the_save_flag_skips_the_question() {
1066 let two = || Fake(Ok(vec![c("ethereum", "0xA"), c("bsc", "0xB")]));
1067 let mut store = Store::default();
1068 let picker = Scripted::new(Some(0), false);
1069 resolve(
1070 "USDN",
1071 &ctx(VenueKind::Dex),
1072 &two(),
1073 &picker,
1074 &mut store,
1075 &out(),
1076 )
1077 .await
1078 .unwrap();
1079 assert!(store.0.is_empty());
1080
1081 let mut cx = ctx(VenueKind::Dex);
1083 cx.save = true;
1084 let picker = Scripted::new(Some(0), false);
1085 resolve("USDN", &cx, &two(), &picker, &mut store, &out())
1086 .await
1087 .unwrap();
1088 assert_eq!(store.0.len(), 1);
1089 assert!(!picker.asked().iter().any(|a| a.starts_with("confirm")));
1090 }
1091
1092 #[tokio::test]
1093 async fn test_yes_with_an_ambiguous_symbol_stops_with_the_candidates() {
1094 let mut cx = ctx(VenueKind::Dex);
1096 cx.yes = true;
1097 let err = resolve(
1098 "USDN",
1099 &cx,
1100 &Fake(Ok(vec![c("ethereum", "0xA"), c("bsc", "0xB")])),
1101 &Scripted::new(Some(0), true),
1102 &mut Store::default(),
1103 &out(),
1104 )
1105 .await
1106 .unwrap_err();
1107 let text = err.to_string();
1108 assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{text}");
1109 assert!(
1110 text.contains("ethereum 0xA") && text.contains("bsc 0xB"),
1111 "{text}"
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn test_no_terminal_picker_lists_every_candidate_and_the_hint() {
1117 let err = NoTerminalPicker
1118 .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1119 .await
1120 .unwrap_err();
1121 let text = err.to_string();
1122 assert!(
1123 text.contains("ethereum 0xA") && text.contains("bsc 0xB"),
1124 "{text}"
1125 );
1126 assert!(text.contains("--token"), "{text}");
1127 assert!(!NoTerminalPicker.confirm("save?").await.unwrap());
1128 }
1129
1130 #[tokio::test]
1131 async fn test_pane_commands_never_use_the_terminal_picker() {
1132 let (o, _cap) = Output::capture_merged();
1135 assert!(o.is_tui());
1136 let p = picker_for(&o, false);
1137 let r = p
1138 .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1139 .await;
1140 assert!(matches!(r, Err(ScopeError::AmbiguousToken { .. })));
1141 }
1142
1143 #[tokio::test]
1144 async fn test_a_server_output_never_prompts() {
1145 let o = Output::stdio().without_prompts();
1147 let p = picker_for(&o, false);
1148 let r = p
1149 .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1150 .await;
1151 assert!(matches!(r, Err(ScopeError::AmbiguousToken { .. })));
1152 }
1153
1154 #[tokio::test]
1155 async fn test_picker_for_json_never_prompts() {
1156 let (o, _cap) = Output::capture();
1158 let p = picker_for(&o, true);
1159 let r = p
1160 .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1161 .await;
1162 assert!(matches!(r, Err(ScopeError::AmbiguousToken { .. })));
1163 }
1164
1165 #[test]
1166 fn test_venue_candidates_are_one_per_network() {
1167 use scope::market::assets::{AssetInfo, AssetNetwork};
1168 let assets = vec![
1169 AssetInfo {
1170 symbol: "USDN".into(),
1171 networks: vec![
1172 AssetNetwork {
1173 network: "ETH".into(),
1174 contract: Some("0xA".into()),
1175 },
1176 AssetNetwork {
1177 network: "BSC".into(),
1178 contract: Some("0xB".into()),
1179 },
1180 ],
1181 },
1182 AssetInfo {
1183 symbol: "BTC".into(),
1184 networks: vec![],
1185 },
1186 ];
1187 let cs = venue_candidates_from("usdn", &assets);
1188 assert_eq!(cs.len(), 2);
1189 assert_eq!(cs[0].address.as_deref(), Some("0xA"));
1190 assert_eq!(cs[0].chain, "ETH", "the venue's name; resolve maps it");
1191 }
1192
1193 #[test]
1194 fn test_dex_results_become_candidates_with_liquidity() {
1195 let r = scope::chains::dex::TokenSearchResult {
1196 address: "0xA".into(),
1197 symbol: "USDN".into(),
1198 name: "USDN".into(),
1199 chain: "ethereum".into(),
1200 price_usd: None,
1201 volume_24h: 0.0,
1202 liquidity_usd: 5.0,
1203 market_cap: None,
1204 };
1205 assert_eq!(dex_candidate(&r).liquidity_usd, Some(5.0));
1206 let mut f = r.clone();
1208 f.address = String::new();
1209 assert_eq!(dex_candidate(&f).address, None);
1210 }
1211
1212 #[test]
1213 fn test_the_alias_file_is_a_binding_store() {
1214 let symbol = "BINDING_STORE_TEST";
1218 let mut aliases = TokenAliases::default();
1219 let store: &mut dyn BindingStore = &mut aliases;
1220 let candidate = Candidate {
1221 symbol: symbol.into(),
1222 ..c("bsc", "0xB")
1223 };
1224 store.save(&candidate, Some("weex")).unwrap();
1225 let got = store.get(symbol, Some("bsc")).unwrap();
1226 assert_eq!(
1227 (got.address.as_str(), got.venue.as_deref()),
1228 ("0xB", Some("weex"))
1229 );
1230 let on_disk = TokenAliases::load().get(symbol, Some("bsc")).cloned();
1231 aliases.remove(symbol, None);
1232 let _ = TokenAliases::save(&aliases);
1233 assert_eq!(on_disk.map(|t| t.address).as_deref(), Some("0xB"));
1234 }
1235
1236 #[tokio::test]
1237 async fn test_save_flag_saves_a_single_candidate_too() {
1238 let mut store = Store::default();
1240 let mut cx = ctx(VenueKind::Dex);
1241 cx.save = true;
1242 resolve(
1243 "USDN",
1244 &cx,
1245 &Fake(Ok(vec![c("ethereum", "0xA")])),
1246 &Scripted::new(None, false),
1247 &mut store,
1248 &out(),
1249 )
1250 .await
1251 .unwrap();
1252 assert_eq!(store.0.len(), 1);
1253 }
1254
1255 #[tokio::test]
1256 async fn test_bindings_on_several_chains_are_a_choice_without_a_chain() {
1257 let mut store = Store(vec![
1260 binding("bsc", "0xB", None),
1261 binding("solana", "So1", None),
1262 ]);
1263 let mut cx = ctx(VenueKind::Dex);
1264 cx.chain = None;
1265 let picker = Scripted::new(Some(1), true);
1266 let r = resolve("USDN", &cx, &Fake(Ok(vec![])), &picker, &mut store, &out())
1267 .await
1268 .unwrap();
1269 assert_eq!(r.address.as_deref(), Some("So1"));
1270 assert!(
1271 picker.asked().iter().any(|a| a.starts_with("pick USDN 2")),
1272 "{:?}",
1273 picker.asked()
1274 );
1275 assert_eq!(store.0.len(), 2, "no new binding: both are saved already");
1276 }
1277
1278 #[tokio::test]
1279 async fn test_a_binding_hit_says_which_token() {
1280 let (o, cap) = Output::capture();
1281 let mut store = Store(vec![binding("ethereum", "0xSAVED", None)]);
1282 resolve(
1283 "USDN",
1284 &ctx(VenueKind::Dex),
1285 &Fake(Ok(vec![])),
1286 &Scripted::new(None, false),
1287 &mut store,
1288 &o,
1289 )
1290 .await
1291 .unwrap();
1292 assert!(cap.err().contains("0xSAVED"), "{}", cap.err());
1293 }
1294
1295 #[tokio::test]
1296 async fn test_a_venue_without_the_bindings_chain_stops() {
1297 let mut store = Store(vec![binding("ethereum", "0xAAA", None)]);
1299 let venue = Fake(Ok(vec![c("TRC20", "TXYZ")]));
1300 let err = resolve(
1301 "USDN",
1302 &ctx(cex()),
1303 &venue,
1304 &Scripted::new(None, false),
1305 &mut store,
1306 &out(),
1307 )
1308 .await
1309 .unwrap_err();
1310 assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1311 assert!(err.to_string().contains("TRC20"), "{err}");
1312 }
1313
1314 #[tokio::test]
1315 async fn test_a_token_flag_with_no_chain_takes_the_address_chain() {
1316 let ctx = |token: &str| ResolveContext {
1319 chain: None,
1320 token_flag: Some(token.into()),
1321 ..ctx(VenueKind::Dex)
1322 };
1323 for (token, chain) in [
1324 ("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum"),
1325 ("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "tron"),
1326 ] {
1327 let r = resolve(
1328 "USDC",
1329 &ctx(token),
1330 &Fake(Ok(vec![])),
1331 &Scripted::new(None, false),
1332 &mut Store::default(),
1333 &out(),
1334 )
1335 .await
1336 .unwrap();
1337 assert_eq!(r.chain, chain, "{token}");
1338 }
1339 let r = resolve(
1341 "USDC",
1342 &ResolveContext {
1343 chain: Some("base".into()),
1344 ..ctx("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
1345 },
1346 &Fake(Ok(vec![])),
1347 &Scripted::new(None, false),
1348 &mut Store::default(),
1349 &out(),
1350 )
1351 .await
1352 .unwrap();
1353 assert_eq!(r.chain, "base");
1354 }
1355
1356 #[tokio::test]
1357 async fn test_a_saved_native_coin_has_no_contract_and_says_so() {
1358 let native = |chain: &str| Candidate {
1360 address: None,
1361 ..c(chain, "")
1362 };
1363 let (o, cap) = Output::capture();
1364 let mut store = Store(vec![binding("ethereum", "", Some("weex"))]);
1365 let r = resolve(
1366 "USDN",
1367 &ctx(cex()),
1368 &Fake(Ok(vec![native("ETH")])),
1369 &Scripted::new(None, false),
1370 &mut store,
1371 &o,
1372 )
1373 .await
1374 .unwrap();
1375 assert_eq!(r.address, None, "no empty contract");
1376 assert!(
1377 cap.err().contains("ethereum its native coin"),
1378 "{}",
1379 cap.err()
1380 );
1381
1382 let mut store = Store(vec![binding("ethereum", "", Some("weex"))]);
1385 let err = resolve(
1386 "USDN",
1387 &ctx(cex()),
1388 &Fake(Ok(vec![c("ETH", "0xAAA")])),
1389 &Scripted::new(None, false),
1390 &mut store,
1391 &out(),
1392 )
1393 .await
1394 .unwrap_err();
1395 assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1396 assert!(err.to_string().contains("its native coin"), "{err}");
1397 assert!(!err.to_string().contains("is , "), "{err}");
1398 }
1399
1400 #[test]
1401 fn test_the_terminal_confirm_takes_y_and_yes_in_any_case() {
1402 for (answer, yes) in [
1404 ("y\n", true),
1405 ("yes\n", true),
1406 ("Y\n", true),
1407 (" YES \n", true),
1408 ("\n", false),
1409 ("n\n", false),
1410 ("yep\n", false),
1411 ("", false),
1412 ] {
1413 let mut reader = std::io::Cursor::new(answer.as_bytes());
1414 assert_eq!(
1415 confirm_from(&mut reader, &out(), "Save? [y/N]: ").unwrap(),
1416 yes,
1417 "{answer:?}"
1418 );
1419 }
1420 }
1421
1422 struct Unlisted;
1424 #[async_trait::async_trait]
1425 impl CandidateSource for Unlisted {
1426 async fn candidates(&self, _s: &str, _c: Option<&str>) -> Result<Vec<Candidate>> {
1427 Ok(vec![])
1428 }
1429 fn searched(&self, _c: Option<&str>) -> String {
1430 "nothing".into()
1431 }
1432 fn lists_contracts(&self) -> bool {
1433 false
1434 }
1435 }
1436
1437 #[tokio::test]
1438 async fn test_a_venue_that_lists_no_contracts_is_not_checked_and_says_so() {
1439 let (o, cap) = Output::capture();
1442 let r = resolve(
1443 "USDC",
1444 &ctx(cex()),
1445 &Unlisted,
1446 &Scripted::new(None, false),
1447 &mut Store::default(),
1448 &o,
1449 )
1450 .await
1451 .unwrap();
1452 assert!(matches!(r.source, Source::Unchecked));
1453 assert!(!cap.err().contains("native"), "{}", cap.err());
1454 let (o, cap) = Output::capture();
1455 let mut store = Store(vec![binding("ethereum", "0xAAA", None)]);
1456 resolve(
1457 "USDN",
1458 &ctx(cex()),
1459 &Unlisted,
1460 &Scripted::new(None, false),
1461 &mut store,
1462 &o,
1463 )
1464 .await
1465 .unwrap();
1466 assert!(cap.err().contains("cannot be checked"), "{}", cap.err());
1467 }
1468
1469 struct ReadOnly;
1471 impl BindingStore for ReadOnly {
1472 fn get(&self, _s: &str, _c: Option<&str>) -> Option<TokenInfo> {
1473 None
1474 }
1475 fn all(&self, _s: &str) -> Vec<TokenInfo> {
1476 Vec::new()
1477 }
1478 fn save(&mut self, _c: &Candidate, _v: Option<&str>) -> Result<()> {
1479 Err(ScopeError::Other("read-only".into()))
1480 }
1481 }
1482
1483 #[tokio::test]
1484 async fn test_a_failed_save_warns_and_keeps_the_choice() {
1485 let (o, cap) = Output::capture();
1488 let r = resolve(
1489 "USDN",
1490 &ctx(VenueKind::Dex),
1491 &Fake(Ok(vec![c("ethereum", "0xA"), c("bsc", "0xB")])),
1492 &Scripted::new(Some(0), true),
1493 &mut ReadOnly,
1494 &o,
1495 )
1496 .await
1497 .unwrap();
1498 assert_eq!(r.address.as_deref(), Some("0xA"));
1499 assert!(matches!(r.source, Source::Picked { saved: false }));
1500 assert!(cap.err().contains("not saved"), "{}", cap.err());
1501 }
1502
1503 fn answer(asker: &crate::cli::output::Asker, answer: Option<usize>) {
1505 let asker = asker.clone();
1506 tokio::spawn(async move {
1507 loop {
1508 let ask = asker.lock().unwrap().take();
1509 if let Some(ask) = ask {
1510 let _ = ask.reply.send(answer);
1511 return;
1512 }
1513 tokio::task::yield_now().await;
1514 }
1515 });
1516 }
1517
1518 #[tokio::test]
1519 async fn test_the_tui_picker_asks_through_the_slot() {
1520 let asker = crate::cli::output::Asker::default();
1521 let p = TuiPicker {
1522 asker: asker.clone(),
1523 };
1524 answer(&asker, Some(1));
1525 let cs = [c("ethereum", "0xA"), c("bsc", "0xB")];
1526 assert_eq!(p.pick("USDN", &cs).await.unwrap(), 1);
1527 answer(&asker, Some(1));
1528 assert!(
1529 p.confirm("Save so …? [y/N]: ").await.unwrap(),
1530 "option 1 is Yes"
1531 );
1532 }
1533
1534 #[tokio::test]
1535 async fn test_the_save_list_title_has_no_terminal_hint() {
1536 let asker = crate::cli::output::Asker::default();
1537 let p = TuiPicker {
1538 asker: asker.clone(),
1539 };
1540 let slot = asker.clone();
1541 let title = tokio::spawn(async move {
1542 loop {
1543 let ask = slot.lock().unwrap().take();
1544 if let Some(ask) = ask {
1545 let _ = ask.reply.send(Some(0));
1546 return ask.prompt;
1547 }
1548 tokio::task::yield_now().await;
1549 }
1550 });
1551 assert!(!p.confirm("Save so X? [y/N]: ").await.unwrap());
1552 assert_eq!(title.await.unwrap(), "Save so X?");
1553 }
1554
1555 #[tokio::test]
1556 async fn test_escape_on_the_choice_is_a_cancel() {
1557 let asker = crate::cli::output::Asker::default();
1558 let p = TuiPicker {
1559 asker: asker.clone(),
1560 };
1561 answer(&asker, None);
1562 let cs = [c("ethereum", "0xA"), c("bsc", "0xB")];
1563 let err = p.pick("USDN", &cs).await.unwrap_err();
1564 assert!(err.to_string().contains("cancelled"), "{err}");
1565 answer(&asker, None);
1566 assert!(
1567 !p.confirm("Save?").await.unwrap(),
1568 "Esc on the save question is No"
1569 );
1570 }
1571
1572 #[tokio::test]
1573 async fn test_a_pane_output_with_an_asker_gets_the_tui_picker() {
1574 let (o, _cap) = Output::capture_merged();
1575 let asker = crate::cli::output::Asker::default();
1576 let o = o.with_asker(asker.clone());
1577 let cs = [c("ethereum", "0xA"), c("bsc", "0xB")];
1578 let p = picker_for(&o, false);
1579 answer(&asker, Some(0));
1580 assert_eq!(p.pick("USDN", &cs).await.unwrap(), 0);
1581 let p = picker_for(&o, true);
1583 assert!(p.pick("USDN", &cs).await.is_err());
1584 }
1585
1586 #[tokio::test]
1587 async fn test_each_resolution_writes_one_log_line() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let log = dir.path().join("resolutions.jsonl");
1590 let picker = Scripted::new(None, false);
1591 resolve_logged(
1592 "USDN",
1593 &ctx(VenueKind::Dex),
1594 &Fake(Ok(vec![c("ethereum", "0xA")])),
1595 &picker,
1596 &mut Store::default(),
1597 &out(),
1598 &log,
1599 )
1600 .await
1601 .unwrap();
1602 let _ = resolve_logged(
1603 "NOPE",
1604 &ctx(VenueKind::Dex),
1605 &Fake(Ok(vec![])),
1606 &picker,
1607 &mut Store::default(),
1608 &out(),
1609 &log,
1610 )
1611 .await;
1612 let lines: Vec<serde_json::Value> = std::fs::read_to_string(&log)
1613 .unwrap()
1614 .lines()
1615 .map(|l| serde_json::from_str(l).unwrap())
1616 .collect();
1617 assert_eq!(lines.len(), 2);
1618 assert_eq!(lines[0]["source"], "single");
1619 assert_eq!(lines[0]["chosen"], "0xA");
1620 assert!(lines[1]["error"].as_str().unwrap().contains("NOPE"));
1621 }
1622}