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