Skip to main content

scope_cli/cli/
resolve.rs

1//! One resolver for every command that takes a token symbol
2//! (docs/design/2026-10-15-token-disambiguation-design.md).
3//!
4//! Steps: `--token` → an address → a saved binding (checked against a CEX
5//! venue's coin list) → the candidates → 0 stops, 1 is used, more go to
6//! the picker → the save question. It never chooses silently.
7
8use 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/// One token a symbol could mean.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Candidate {
18    pub symbol: String,
19    pub name: String,
20    /// The chain, or the venue's network name for a CEX candidate.
21    pub chain: String,
22    /// The contract; `None` for a native coin or an unknown contract.
23    pub address: Option<String>,
24    pub price_usd: Option<f64>,
25    pub liquidity_usd: Option<f64>,
26}
27
28/// Where the command reads its market data.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum VenueKind {
31    Dex,
32    /// A CEX venue, by id.
33    Cex(String),
34}
35
36/// What the command knows before the lookup.
37#[derive(Debug, Clone)]
38pub struct ResolveContext {
39    /// The chain filter, when the user set one.
40    pub chain: Option<String>,
41    pub venue: VenueKind,
42    /// `--token <contract>`.
43    pub token_flag: Option<String>,
44    /// `--yes`: no save question; an ambiguous symbol stops.
45    pub yes: bool,
46    /// `--save`: save the choice without a question.
47    pub save: bool,
48}
49
50/// How the token was chosen.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Source {
53    Flag,
54    Address,
55    Binding,
56    Single,
57    Picked {
58        saved: bool,
59    },
60    /// A CEX venue that does not list contracts: the symbol is used as is.
61    Unchecked,
62}
63
64/// The token a command uses.
65#[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/// Where candidates come from: a DEX search or a venue's coin list.
75#[async_trait::async_trait]
76pub trait CandidateSource: Send + Sync {
77    async fn candidates(&self, symbol: &str, chain: Option<&str>) -> Result<Vec<Candidate>>;
78    /// What was searched, for a "not found" error.
79    fn searched(&self, chain: Option<&str>) -> String;
80    /// False for a CEX venue without a coin list: its symbols cannot be
81    /// checked against contracts.
82    fn lists_contracts(&self) -> bool {
83        true
84    }
85}
86
87/// Asks the user to choose.
88#[async_trait::async_trait]
89pub trait Picker: Send + Sync {
90    /// The index of the chosen candidate.
91    async fn pick(&self, input: &str, candidates: &[Candidate]) -> Result<usize>;
92    /// The answer to a yes/no question.
93    async fn confirm(&self, question: &str) -> Result<bool>;
94}
95
96/// The saved bindings.
97pub trait BindingStore: Send {
98    fn get(&self, symbol: &str, chain: Option<&str>) -> Option<TokenInfo>;
99    /// Every saved binding for `symbol`, on any chain.
100    fn all(&self, symbol: &str) -> Vec<TokenInfo>;
101    fn save(&mut self, candidate: &Candidate, venue: Option<&str>) -> Result<()>;
102}
103
104/// One line for a candidate in lists and errors: chain, contract, name.
105pub 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
114/// Two contracts are the same token: EVM hex compares without case.
115fn 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
123/// A DexScreener search result as a candidate. An empty address (the CEX
124/// ticker fallback) is a token without a contract.
125pub(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
136/// The venue's networks for `symbol`, one candidate each.
137pub(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
157/// Candidates from a DexScreener search, with the CEX ticker fallback when
158/// `fallback` (its transport) is set and the search finds nothing.
159pub 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        // A set chain is a guarantee: never offer a token from another one.
170        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
202/// Candidates from a CEX venue's coin list (the `assets` capability).
203pub 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            // No `assets` capability: the venue symbol is all that is known.
213            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
254/// Asks on the terminal: the numbered token list, then yes/no.
255pub 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
272/// Asks `question` and reads the answer from `reader`: yes for `y` or
273/// `yes` in any case, no for anything else.
274fn 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
282/// Asks in the TUI: puts the question in the pane's ask slot and waits for
283/// the list's answer.
284pub struct TuiPicker {
285    pub asker: crate::cli::output::Asker,
286}
287
288impl TuiPicker {
289    /// Asks the TUI `prompt` with `options` in its list; `None` on cancel.
290    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        // One question at a time: wait until the slot is free. The lock
298        // is held only inside the block, never across an await.
299        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                // A pause, not a yield: a yield wakes the TUI's task at
311                // once, and it would spin until the open question is
312                // answered.
313                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        // The list is the answer: the terminal's "[y/N]: " does not apply.
333        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
343/// Never asks: a choice is an error that lists the candidates.
344pub 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
360/// The picker for this run: never for JSON output; the TUI's list when
361/// the TUI pane shows the output; no prompt in a server or without a
362/// terminal; else the terminal.
363pub 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
379/// [`resolve_logged`] with the log in the scope state folder.
380pub 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
392/// Resolves `input`, then appends one line to `log` (the RCA trail). A
393/// log failure is a warning.
394pub 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    // With no chain set, an address names its chain where its format
422    // does, for `--token` as for a positional address (#108).
423    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    // 1. --token, 2. an address
435    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    // 3. a saved binding, checked against a CEX venue's coin list. With
442    // no chain and bindings on several chains, they are the choice.
443    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    // A venue without a coin list: nothing to choose from or check.
482    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    // 4. the candidates: exact symbols, or the exact contract when the
492    // input is one that is not in a known address format
493    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    // 5. none, one, or a choice
506    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    // 6. the save question
529    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
541/// Saves `chosen` under its scope chain name. A failure is a note: the
542/// token is already chosen, so the command goes on.
543fn 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
573/// A saved binding's contract. A native coin (a CEX pick with no
574/// contract) is saved with an empty address and has none (#108).
575fn binding_contract(t: &TokenInfo) -> Option<&str> {
576    (!t.address.is_empty()).then_some(t.address.as_str())
577}
578
579/// A saved binding's contract for a message, or "its native coin".
580fn binding_label(t: &TokenInfo) -> &str {
581    binding_contract(t).unwrap_or(NATIVE_COIN)
582}
583
584/// How a message names a token with no contract.
585const NATIVE_COIN: &str = "its native coin";
586
587/// A saved binding as a candidate, for a choice between bindings.
588fn 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
599/// Checks `saved` against a CEX venue's coin list: a different contract,
600/// or the symbol listed only on other networks, stops. A venue without a
601/// list, or a failed fetch, gives a note.
602async 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            // A saved native coin that the venue lists as a contract.
647            (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    /// Answers with `pick` and `confirm`, and records what it was asked.
746    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        // Review focus 2: the venue calls it ERC20; the binding says ethereum.
907        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        // Review focus 3.
925        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        // Review focus 4: a network error must not block a saved binding.
951        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        // Review focus 1: USDNX is not USDN.
1005        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        // An input that is a contract in no known address format still
1025        // means the candidate with that contract, not a symbol search miss.
1026        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        // --save answers yes without a question.
1082        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        // Jon, 2026-10-15: --yes never picks the first match.
1095        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        // Review focus 5: in the TUI pane a prompt would read the TUI's
1133        // stdin and hang.
1134        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        // scope web passes stdio output; a request must not wait on stdin.
1146        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        // A JSON run must not wait for stdin.
1157        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        // The CEX ticker fallback has no address: no contract.
1207        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        // This writes the test process's one tokens.yaml, which other
1215        // tests read (a saved choice for their symbol settles an ambiguity
1216        // they expect), so it saves a symbol no other test uses.
1217        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        // Review I2: --save must save even when there was nothing to pick.
1239        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        // Review I3: no chain set, USDN saved on bsc and solana: ask, never
1258        // pick one by map order.
1259        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        // Review I4: the venue lists USDN, but not on the saved chain.
1298        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        // #108 1: `--token <address>` infers the chain, as a positional
1317        // address does.
1318        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        // A chain the user set still wins.
1340        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        // #108 2: a native-coin binding is saved with an empty address.
1359        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        // The venue now lists a contract on that chain: a mismatch that
1383        // names the native coin, never an empty contract.
1384        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        // #108 4.
1403        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    /// A CEX venue that does not list contracts (no `assets`).
1423    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        // Review I5: no false "Using … native" line; a binding gets the
1440        // spec's "cannot be checked" note.
1441        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    /// A store whose save always fails.
1470    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        // Review I7: the token was chosen; a save failure must not fail
1486        // the command.
1487        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    /// Answers the next question in `asker` with `answer`.
1504    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        // JSON still never asks.
1582        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}