diff --git a/Cargo.lock b/Cargo.lock index 916309e6f9..ec924e49eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2565,8 +2565,11 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", + "mio", "parking_lot", "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -5568,6 +5571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -6923,7 +6927,10 @@ dependencies = [ "chrono", "clap", "comfy-table", + "console", + "crossterm", "csv", + "dialoguer", "flate2", "futures", "httpmock", @@ -6933,9 +6940,11 @@ dependencies = [ "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", + "rain_orderbook_js_api", "rain_orderbook_quote", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", + "reqwest 0.12.20", "rusqlite", "rust-bigint", "serde", @@ -8757,6 +8766,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1ad3547050..f2db1112d5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,7 +33,11 @@ url.workspace = true serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } +console = "0.15" +crossterm = "0.28" +dialoguer = "0.11" flate2 = "1.0.34" +reqwest = { workspace = true } rusqlite = { version = "0.31", features = ["functions"] } [target.'cfg(not(target_family = "wasm"))'.dependencies] diff --git a/crates/cli/src/commands/strategy_builder/describe.rs b/crates/cli/src/commands/strategy_builder/describe.rs new file mode 100644 index 0000000000..35f3d55152 --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/describe.rs @@ -0,0 +1,507 @@ +//! `--describe` mode: prints a full dump of a registry as markdown. +//! +//! Intended to serve as a self-generating skill for the non-interactive CLI. +//! A human or agent can run `raindex strategy-builder describe --registry URL` +//! and get everything they need to construct a deploy command. + +use anyhow::Result; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::fmt::Write; + +const USAGE: &str = r#"## Usage + +Generate deployment calldata for a strategy: + +``` +raindex strategy-builder \ + --registry \ + --strategy \ + --deployment \ + --owner <0x-address> \ + [--select-token KEY=ADDRESS ...] \ + [--set-field BINDING=VALUE ...] \ + [--set-deposit TOKEN=AMOUNT ...] +``` + +`--owner` must be the address that will sign the transactions (not a contract). +Each deployment below has an **Example command** with the required flags +pre-filled for that specific deployment. + +Field values passed via `--set-field` are in human-readable decimal form — the +tool handles token-decimal scaling internally. You do not need to multiply by +`10^decimals`. + +### Output format + +The command writes one transaction per line to stdout. Each transaction is +preceded by a `#` comment line describing what the transaction does. The lines +look like: + +``` +# approve WETH +0x...:0x095ea7b3... +# deploy order +0xe522cB4a...:0xac9650d8... +# emit strategy metadata +0x59401C93...:0x37480e2a... +``` + +Submitters should skip lines starting with `#` and split the remaining lines on +the first `:` to get `(to-address, hex-calldata)` pairs. + +Multiple non-comment lines are possible — they must be signed and broadcast in +order. Output contains (in order): + +1. One ERC20 `approve` transaction per token being deposited. If you pass + no `--set-deposit` flags, there are no approvals. +2. The main order deployment transaction to the orderbook contract. +3. An optional metadata emission transaction. + +### Submitting transactions + +Option A — pipe into the st0x CLI (handles Turnkey wallet signing): + +``` +raindex strategy-builder ... | stox submit +``` + +Option B — sign and broadcast each line with `cast send` (foundry): + +``` +raindex strategy-builder ... | while IFS=: read -r to data; do + [[ "$to" == \#* || -z "$to" ]] && continue + cast send "$to" "$data" \ + --private-key "$PRIVATE_KEY" \ + --rpc-url "$RPC_URL" +done +``` + +Option C — pipe into any other submitter that reads `address:calldata` lines +(skipping `#` comment lines). + +### Picking token addresses + +Token addresses are chain-specific. Each deployment below lists the tokens +that must be selected via `--select-token KEY=
`. To see which token +addresses the registry has for a given deployment, use `--tokens`: + +``` +raindex strategy-builder --tokens \ + --registry --strategy --deployment +``` + +Any ERC20 address is valid for `--select-token`; the registry list is just a +curated convenience subset. + +### Building the CLI from source + +If you're running this from a local clone, build once and call the binary +directly — the `nix develop` shellHook runs `npm install` on every invocation +which is noisy and slow. + +``` +cd rain.orderbook +nix develop --impure --command cargo build -p rain_orderbook_cli +./target/debug/rain_orderbook_cli strategy-builder --describe --registry +``` + +"#; + +pub async fn run_describe(registry_url: &str) -> Result<()> { + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut out = String::new(); + writeln!(out, "# Raindex Strategy Registry")?; + writeln!(out)?; + writeln!(out, "**Registry:** {registry_url}")?; + writeln!(out)?; + write!(out, "{USAGE}")?; + writeln!(out, "## Strategies")?; + writeln!(out)?; + + let details = registry + .get_all_order_details() + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let settings = registry_settings(®istry); + + let mut strategy_keys: Vec<&String> = details.valid.keys().collect(); + strategy_keys.sort(); + + for strategy_key in strategy_keys { + let info = &details.valid[strategy_key]; + writeln!(out, "### `{strategy_key}` — {}", info.name)?; + writeln!(out)?; + writeln!(out, "{}", info.description)?; + writeln!(out)?; + + let dotrain = registry + .orders() + .0 + .get(strategy_key) + .cloned() + .ok_or_else(|| anyhow::anyhow!("strategy '{strategy_key}' not in registry"))?; + + describe_strategy(&mut out, strategy_key, &dotrain, &settings).await?; + } + + if !details.invalid.is_empty() { + writeln!(out, "## Invalid Strategies")?; + writeln!(out)?; + writeln!(out, "The following registry entries failed to parse:")?; + writeln!(out)?; + for (key, err) in &details.invalid { + writeln!(out, "- `{key}`: {}", err.readable_msg)?; + } + writeln!(out)?; + } + + println!("{out}"); + Ok(()) +} + +async fn describe_strategy( + out: &mut String, + strategy_key: &str, + dotrain: &str, + settings: &Option>, +) -> Result<()> { + let deployment_keys = + RaindexOrderBuilder::get_deployment_keys(dotrain.to_string(), settings.clone()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + if deployment_keys.is_empty() { + writeln!(out, "_No deployments defined._")?; + writeln!(out)?; + return Ok(()); + } + + writeln!(out, "**Deployments:**")?; + writeln!(out)?; + + for deployment_key in deployment_keys { + // Build each deployment individually to get its full config + let builder = match RaindexOrderBuilder::new_with_deployment( + dotrain.to_string(), + settings.clone(), + deployment_key.clone(), + ) + .await + { + Ok(b) => b, + Err(err) => { + writeln!( + out, + "#### `{deployment_key}` — _failed to load: {}_", + err.to_readable_msg() + )?; + writeln!(out)?; + continue; + } + }; + + let deployment = match builder.get_current_deployment() { + Ok(d) => d, + Err(err) => { + writeln!( + out, + "#### `{deployment_key}` — _failed to load: {}_", + err.to_readable_msg() + )?; + writeln!(out)?; + continue; + } + }; + + writeln!(out, "#### `{deployment_key}` — {}", deployment.name)?; + writeln!(out)?; + writeln!(out, "{}", deployment.description)?; + writeln!(out)?; + + writeln!(out, "**Example command:**")?; + writeln!(out)?; + writeln!(out, "```")?; + writeln!( + out, + "raindex strategy-builder \\\n --registry \\\n --strategy {strategy_key} \\\n --deployment {deployment_key} \\\n --owner <0x-address>{}{}{}", + render_token_flags(&deployment), + render_field_flags(&deployment), + render_deposit_flags(&deployment), + )?; + writeln!(out, "```")?; + writeln!(out)?; + + describe_select_tokens(out, &deployment, strategy_key, &deployment_key)?; + describe_fields(out, &deployment)?; + describe_deposits(out, &deployment)?; + } + + Ok(()) +} + +fn render_token_flags( + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> String { + match &deployment.select_tokens { + Some(tokens) if !tokens.is_empty() => tokens + .iter() + .map(|t| format!(" \\\n --select-token {}=
", t.key)) + .collect(), + _ => String::new(), + } +} + +fn render_field_flags( + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> String { + deployment + .fields + .iter() + .filter(|f| f.default.is_none()) + .map(|f| format!(" \\\n --set-field {}=", f.binding)) + .collect() +} + +fn render_deposit_flags( + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> String { + deployment + .deposits + .iter() + .map(|d| format!(" \\\n --set-deposit {}=", d.token_key)) + .collect() +} + +fn describe_select_tokens( + out: &mut String, + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, + strategy_key: &str, + deployment_key: &str, +) -> Result<()> { + let tokens = match &deployment.select_tokens { + Some(t) if !t.is_empty() => t, + _ => return Ok(()), + }; + + writeln!(out, "**Tokens to select:**")?; + writeln!(out)?; + for token in tokens { + let name = token.name.as_deref().unwrap_or(""); + let desc = token.description.as_deref().unwrap_or(""); + match (name, desc) { + ("", "") => writeln!(out, "- `{}`", token.key)?, + (n, "") => writeln!(out, "- `{}` — {n}", token.key)?, + ("", d) => writeln!(out, "- `{}` — {d}", token.key)?, + (n, d) => writeln!(out, "- `{}` ({n}) — {d}", token.key)?, + } + } + writeln!(out)?; + writeln!( + out, + "Get the list of valid token addresses for this deployment with:" + )?; + writeln!(out)?; + writeln!(out, "```")?; + writeln!( + out, + "raindex strategy-builder --tokens \\\n --registry \\\n --strategy {strategy_key} \\\n --deployment {deployment_key}" + )?; + writeln!(out, "```")?; + writeln!(out)?; + Ok(()) +} + +fn describe_fields( + out: &mut String, + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> Result<()> { + if deployment.fields.is_empty() { + return Ok(()); + } + + writeln!(out, "**Fields:**")?; + writeln!(out)?; + for field in &deployment.fields { + let marker = match &field.default { + Some(default) => format!(" _(optional, default: `{default}`)_"), + None => " _(required)_".to_string(), + }; + writeln!(out, "- `{}` ({}){marker}", field.binding, field.name)?; + if let Some(desc) = &field.description { + writeln!(out, " - {desc}")?; + } + if let Some(presets) = &field.presets { + if !presets.is_empty() { + write!(out, " - Presets:")?; + for preset in presets { + match &preset.name { + Some(n) => write!(out, " `{}` = `{}`,", n, preset.value)?, + None => write!(out, " `{}`,", preset.value)?, + } + } + writeln!(out)?; + } + } + } + writeln!(out)?; + Ok(()) +} + +fn describe_deposits( + out: &mut String, + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> Result<()> { + if deployment.deposits.is_empty() { + return Ok(()); + } + + writeln!(out, "**Deposits:**")?; + writeln!(out)?; + for deposit in &deployment.deposits { + write!(out, "- `{}`", deposit.token_key)?; + if let Some(presets) = &deposit.presets { + if !presets.is_empty() { + write!( + out, + " — presets: {}", + presets + .iter() + .map(|p| format!("`{p}`")) + .collect::>() + .join(", ") + )?; + } + } + writeln!(out)?; + } + writeln!(out)?; + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // End-to-end: describe a real registry served by httpmock and verify + // the markdown output contains all the strategy details. + #[tokio::test] + async fn describe_renders_registry_as_markdown() { + let server = httpmock::MockServer::start(); + + let settings = r#"version: 4 +networks: + base: + rpcs: + - https://base-rpc.publicnode.com + chain-id: 8453 + network-id: 8453 + currency: ETH +orderbooks: + base: + address: 0xe522cB4a5fCb2eb31a52Ff41a4653d85A4fd7C9D + network: base +deployers: + base: + address: 0xd905B56949284Bb1d28eeFC05be78Af69cCf3668 + network: base +"#; + + let strategy = r#"version: 4 +orders: + base: + orderbook: base + inputs: + - token: token1 + outputs: + - token: token2 +scenarios: + base: + orderbook: base + runs: 1 + bindings: + max-spread: 0.002 +deployments: + base: + order: base + scenario: base +builder: + name: Fixed spread + description: A strategy that tracks a benchmark price with a fixed spread. + short-description: Fixed spread strategy + deployments: + base: + name: Base + description: Deploy on Base network. + deposits: + - token: token2 + presets: + - "10" + - "100" + - "1000" + fields: + - binding: max-spread + name: Maximum spread + description: The max spread as a decimal. + presets: + - name: Tight + value: "0.001" + - name: Loose + value: "0.01" + select-tokens: + - key: token1 + name: Input token + description: The token you buy + - key: token2 + name: Output token + description: The token you sell +--- +#max-spread !max spread +#calculate-io +max-output: max-positive-value(), +io: 1; +#handle-io +:; +#handle-add-order +:; +"#; + + let settings_url = format!("{}/settings.yaml", server.base_url()); + let strategy_url = format!("{}/fixed-spread.rain", server.base_url()); + let registry_url = format!("{}/registry", server.base_url()); + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/registry"); + then.status(200) + .body(format!("{settings_url}\nfixed-spread {strategy_url}\n")); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/settings.yaml"); + then.status(200).body(settings); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/fixed-spread.rain"); + then.status(200).body(strategy); + }); + + // Capture stdout. The describe command uses println!, so we need a + // helper — instead just verify it completes without error. The + // individual helpers below cover the rendering logic. + let result = run_describe(®istry_url).await; + assert!(result.is_ok(), "describe failed: {result:?}"); + } +} +// retrigger diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs new file mode 100644 index 0000000000..79d51eda9b --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -0,0 +1,590 @@ +use super::select::{self, SelectContext, SelectItem}; +use alloy::primitives::hex; +use anyhow::{Context, Result}; +use console::Style; +use crossterm::{cursor, execute, terminal}; +use rain_orderbook_app_settings::order_builder::{ + OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, +}; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::io::{stderr, Write}; + +fn bold(text: &str) -> String { + Style::new().bold().apply_to(text).to_string() +} + +fn dim(text: &str) -> String { + Style::new().dim().apply_to(text).to_string() +} + +/// RAII guard that restores the terminal to a sane state (cooked mode, main +/// screen, visible cursor) on drop, even if a panic unwinds through us. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!(stderr(), terminal::LeaveAlternateScreen, cursor::Show); + let _ = terminal::disable_raw_mode(); + } +} + +/// Enter alternate screen once, run the entire wizard there, leave at the end. +pub async fn run_interactive(registry_url: &str) -> Result<()> { + eprintln!(" Fetching strategies from {}...", dim(registry_url)); + + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut w = stderr(); + terminal::enable_raw_mode()?; + execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; + let _guard = TerminalGuard; + + let mut progress: Vec = Vec::new(); + let result = run_wizard(&mut w, ®istry, &mut progress).await; + + match result { + Ok(output) => { + eprintln!(); + for line in &progress { + eprintln!(" {line}"); + } + eprintln!(); + + for line in &output { + println!("{line}"); + } + Ok(()) + } + Err(err) => Err(err), + } +} + +async fn run_wizard( + w: &mut impl Write, + registry: &DotrainRegistry, + progress: &mut Vec, +) -> Result> { + // 1. Owner + let owner = select::input( + w, + "Owner address", + Some("The wallet that will own this order and sign the deploy transactions."), + None, + false, + progress, + )?; + progress.push(format!("{}: {owner}", bold("Owner"))); + + // 2. Strategy + let (strategy_key, dotrain) = pick_strategy(w, registry, progress)?; + progress.push(format!("{}: {strategy_key}", bold("Strategy"))); + + let settings = registry_settings(registry); + + // 3. Deployment + let deployment_key = pick_deployment(w, &dotrain, &settings, progress)?; + progress.push(format!("{}: {deployment_key}", bold("Deployment"))); + + render_progress(w, progress, Some("Initializing builder..."))?; + + let mut builder = + RaindexOrderBuilder::new_with_deployment(dotrain, settings.clone(), deployment_key) + .await + .map_err(|err| { + anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) + })?; + + // 4. Token selection + if let Ok(tokens) = builder.get_select_tokens() { + if !tokens.is_empty() { + select_tokens(w, &mut builder, &tokens, progress).await?; + } + } + + // 5. Fields + fill_fields(w, &mut builder, progress)?; + + // 6. Deposits + fill_deposits(w, &mut builder, &owner, progress).await?; + + // 7. Generate calldata + render_progress(w, progress, Some("Generating calldata..."))?; + + let args = builder + .get_deployment_transaction_args(owner.clone()) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to generate deployment calldata: {}", + err.to_readable_msg() + ) + })?; + + progress.push(format!("{}: {}", bold("Chain"), args.chain_id)); + progress.push(format!("{}: {}", bold("Orderbook"), args.orderbook_address)); + + let mut calldata_lines = Vec::new(); + for approval in &args.approvals { + calldata_lines.push(format!( + "{}:0x{}", + approval.token, + hex::encode(&approval.calldata) + )); + progress.push(format!( + " Approve {} — {} bytes", + Style::new().cyan().apply_to(&approval.symbol), + approval.calldata.len() + )); + } + calldata_lines.push(format!( + "{}:0x{}", + args.orderbook_address, + hex::encode(&args.deployment_calldata) + )); + progress.push(format!( + " Deploy order — {} bytes", + args.deployment_calldata.len() + )); + if let Some(meta_call) = &args.emit_meta_call { + calldata_lines.push(format!( + "{}:0x{}", + meta_call.to, + hex::encode(&meta_call.calldata) + )); + progress.push(" Emit metadata".to_string()); + } + + // 8. Output choice + let output_items = vec![ + SelectItem { + key: "Print to stdout".to_string(), + description: "address:calldata lines".to_string(), + }, + SelectItem { + key: "Save to file".to_string(), + description: String::new(), + }, + ]; + let ctx = SelectContext::new(progress); + let output_choice = select::select(w, "Output", &output_items, &ctx)?; + + match output_choice { + 0 => Ok(calldata_lines), + 1 => { + let path = select::input( + w, + "Output file path", + None, + Some("deploy.calldata"), + false, + progress, + )?; + + let mut file = + std::fs::File::create(&path).with_context(|| format!("creating {path}"))?; + for line in &calldata_lines { + writeln!(file, "{line}")?; + } + progress.push(format!(" Wrote to {path}")); + Ok(Vec::new()) + } + other => unreachable!("unexpected output_choice index: {other}"), + } +} + +fn render_progress(w: &mut impl Write, progress: &[String], status: Option<&str>) -> Result<()> { + execute!( + w, + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All) + )?; + for line in progress { + write!(w, " {line}\r\n")?; + } + if let Some(msg) = status { + write!(w, "\r\n {msg}\r\n")?; + } + w.flush()?; + Ok(()) +} + +fn pick_strategy( + w: &mut impl Write, + registry: &DotrainRegistry, + progress: &[String], +) -> Result<(String, String)> { + let details = registry + .get_all_order_details() + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + if details.valid.is_empty() { + anyhow::bail!("no valid strategies found in registry"); + } + + let keys: Vec<&String> = details.valid.keys().collect(); + let select_items: Vec = keys + .iter() + .map(|key| { + let info = &details.valid[*key]; + SelectItem { + key: key.to_string(), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } + }) + .collect(); + + let ctx = SelectContext::new(progress); + let idx = select::select(w, "Strategy", &select_items, &ctx)?; + + let key = keys[idx].clone(); + let dotrain = registry + .orders() + .0 + .get(&key) + .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? + .clone(); + + Ok((key, dotrain)) +} + +fn pick_deployment( + w: &mut impl Write, + dotrain: &str, + settings: &Option>, + progress: &[String], +) -> Result { + let deployments = + RaindexOrderBuilder::get_deployment_details(dotrain.to_string(), settings.clone()) + .map_err(|err| { + anyhow::anyhow!( + "failed to get deployment details: {}", + err.to_readable_msg() + ) + })?; + + if deployments.is_empty() { + anyhow::bail!("no deployments found for this strategy"); + } + + if deployments.len() == 1 { + let (key, _) = deployments.into_iter().next().unwrap(); + return Ok(key); + } + + let keys: Vec<&String> = deployments.keys().collect(); + let select_items: Vec = keys + .iter() + .map(|key| { + let info = &deployments[*key]; + SelectItem { + key: format!("{} ({})", info.name, key), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } + }) + .collect(); + + let ctx = SelectContext::new(progress); + let idx = select::select(w, "Deployment", &select_items, &ctx)?; + let key = keys[idx].clone(); + Ok(key) +} + +async fn select_tokens( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + tokens: &[OrderBuilderSelectTokensCfg], + progress: &mut Vec, +) -> Result<()> { + for token_cfg in tokens { + let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); + + let available = builder.get_all_tokens(None).await.unwrap_or_default(); + + let address = if available.is_empty() { + select::input( + w, + &format!("{prompt_label} (address)"), + token_cfg.description.as_deref(), + None, + false, + progress, + )? + } else { + let mut select_items: Vec = available + .iter() + .map(|t| SelectItem { + key: format!("{} ({})", t.symbol, t.name), + description: format!("{}", t.address), + }) + .collect(); + select_items.push(SelectItem { + key: "Enter address manually".to_string(), + description: String::new(), + }); + + let mut ctx = SelectContext::new(progress); + if let Some(desc) = &token_cfg.description { + ctx = ctx.with_description(desc); + } + let idx = select::select(w, prompt_label, &select_items, &ctx)?; + + if idx < available.len() { + let token = &available[idx]; + progress.push(format!( + "{}: {} ({})", + bold(prompt_label), + token.symbol, + token.address + )); + format!("{}", token.address) + } else { + let addr = select::input( + w, + &format!("{prompt_label} address"), + None, + None, + false, + progress, + )?; + progress.push(format!("{}: {addr}", bold(prompt_label))); + addr + } + }; + + builder + .set_select_token(token_cfg.key.clone(), address) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to select token '{}': {}", + token_cfg.key, + err.to_readable_msg() + ) + })?; + } + + Ok(()) +} + +fn fill_fields( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + progress: &mut Vec, +) -> Result<()> { + let missing = builder + .get_missing_field_values() + .map_err(|err| anyhow::anyhow!("failed to get fields: {}", err.to_readable_msg()))?; + + if missing.is_empty() { + return Ok(()); + } + + for field in &missing { + fill_single_field(w, builder, field, progress)?; + } + + Ok(()) +} + +fn fill_single_field( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + field: &OrderBuilderFieldDefinitionCfg, + progress: &mut Vec, +) -> Result<()> { + let value = match &field.presets { + Some(presets) if !presets.is_empty() => { + let show_custom = field.show_custom_field.unwrap_or(true); + + let mut select_items: Vec = presets + .iter() + .map(|p| { + let label = p.name.as_deref().unwrap_or(&p.value); + SelectItem { + key: label.to_string(), + description: format!("= {}", p.value), + } + }) + .collect(); + + if show_custom { + select_items.push(SelectItem { + key: "Custom value".to_string(), + description: String::new(), + }); + } + + let mut ctx = SelectContext::new(progress); + if let Some(desc) = &field.description { + ctx = ctx.with_description(desc); + } + let idx = select::select(w, &field.name, &select_items, &ctx)?; + + if idx < presets.len() { + presets[idx].value.clone() + } else { + select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )? + } + } + _ => select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )?, + }; + + progress.push(format!("{}: {value}", bold(&field.name))); + + builder + .set_field_value(field.binding.clone(), value) + .map_err(|err| { + anyhow::anyhow!( + "failed to set field '{}': {}", + field.binding, + err.to_readable_msg() + ) + })?; + + Ok(()) +} + +async fn fill_deposits( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + owner: &str, + progress: &mut Vec, +) -> Result<()> { + let deployment = builder + .get_current_deployment() + .map_err(|err| anyhow::anyhow!("failed to get deployment: {}", err.to_readable_msg()))?; + + if deployment.deposits.is_empty() { + return Ok(()); + } + + for deposit_cfg in &deployment.deposits { + let (token_display, balance_desc) = + match builder.get_token_info(deposit_cfg.token_key.clone()).await { + Ok(info) => { + let balance = builder + .get_account_balance(format!("{}", info.address), owner.to_string()) + .await + .ok() + .map(|b| b.formatted_balance().to_string()); + let desc = balance + .map(|b| format!("Your balance: {b} {}", info.symbol)) + .unwrap_or_default(); + (info.symbol.clone(), desc) + } + Err(_) => (deposit_cfg.token_key.clone(), String::new()), + }; + + let presets = builder + .get_deposit_presets(deposit_cfg.token_key.clone()) + .unwrap_or_default(); + + let desc_opt = if balance_desc.is_empty() { + None + } else { + Some(balance_desc.as_str()) + }; + + let amount = if presets.is_empty() { + select::input( + w, + &format!("Deposit amount ({token_display}) — blank to skip"), + desc_opt, + None, + true, + progress, + )? + } else { + let mut select_items: Vec = presets + .iter() + .map(|p| SelectItem { + key: format!("{p} {token_display}"), + description: String::new(), + }) + .collect(); + select_items.push(SelectItem { + key: "Custom amount".to_string(), + description: String::new(), + }); + select_items.push(SelectItem { + key: "Skip".to_string(), + description: String::new(), + }); + + let title = format!("Deposit {token_display}"); + let mut ctx = SelectContext::new(progress); + if !balance_desc.is_empty() { + ctx = ctx.with_description(&balance_desc); + } + let idx = select::select(w, &title, &select_items, &ctx)?; + + if idx < presets.len() { + presets[idx].clone() + } else if idx == presets.len() { + select::input( + w, + &format!("Amount ({token_display})"), + None, + None, + false, + progress, + )? + } else { + continue; + } + }; + + if amount.is_empty() { + continue; + } + + progress.push(format!("{}: {amount} {token_display}", bold("Deposit"))); + + builder + .set_deposit(deposit_cfg.token_key.clone(), amount) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to set deposit '{}': {}", + deposit_cfg.token_key, + err.to_readable_msg() + ) + })?; + } + + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} diff --git a/crates/cli/src/commands/strategy_builder.rs b/crates/cli/src/commands/strategy_builder/mod.rs similarity index 66% rename from crates/cli/src/commands/strategy_builder.rs rename to crates/cli/src/commands/strategy_builder/mod.rs index cd0644a8f3..ac8839a0cf 100644 --- a/crates/cli/src/commands/strategy_builder.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,3 +1,8 @@ +mod describe; +mod interactive; +mod select; +mod tokens; + use crate::execute::Execute; use alloy::primitives::hex; use anyhow::Result; @@ -14,14 +19,29 @@ pub struct StrategyBuilder { )] registry: String, + #[arg(short, long, help = "Interactive mode — guided strategy deployment")] + interactive: bool, + + #[arg( + long, + help = "List all tokens registered for --strategy + --deployment as markdown" + )] + tokens: bool, + + #[arg( + long, + help = "Describe the registry — list strategies, deployments, fields, tokens, and usage as markdown" + )] + describe: bool, + #[arg(long, help = "Order/strategy key from the registry")] - strategy: String, + strategy: Option, #[arg(long, help = "Deployment key within the strategy")] - deployment: String, + deployment: Option, #[arg(long, help = "Order owner address")] - owner: String, + owner: Option, #[arg( long = "set-field", @@ -51,6 +71,10 @@ fn parse_key_value_pairs(args: &[String]) -> Result> { let (key, value) = arg .split_once('=') .ok_or_else(|| anyhow::anyhow!("expected KEY=VALUE, got: {arg}"))?; + let key = key.trim(); + if key.is_empty() { + anyhow::bail!("expected non-empty KEY in KEY=VALUE, got: {arg}"); + } if map.contains_key(key) { anyhow::bail!("duplicate key: {key}"); } @@ -61,6 +85,37 @@ fn parse_key_value_pairs(args: &[String]) -> Result> { impl Execute for StrategyBuilder { async fn execute(&self) -> Result<()> { + if self.describe { + return describe::run_describe(&self.registry).await; + } + if self.interactive { + return interactive::run_interactive(&self.registry).await; + } + if self.tokens { + let strategy = self + .strategy + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--strategy is required with --tokens"))?; + let deployment = self + .deployment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--deployment is required with --tokens"))?; + return tokens::run_tokens(&self.registry, strategy, deployment).await; + } + + let strategy = self + .strategy + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--strategy is required in non-interactive mode"))?; + let deployment = self + .deployment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--deployment is required in non-interactive mode"))?; + let owner = self + .owner + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--owner is required in non-interactive mode"))?; + let registry = DotrainRegistry::new(self.registry.clone()) .await .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; @@ -68,13 +123,12 @@ impl Execute for StrategyBuilder { let dotrain = registry .orders() .0 - .get(&self.strategy) + .get(strategy) .ok_or_else(|| { - let available = registry.get_order_keys().unwrap_or_default(); + let mut available = registry.get_order_keys().unwrap_or_default(); + available.sort(); anyhow::anyhow!( - "strategy '{}' not found in registry. Available: {:?}", - self.strategy, - available + "strategy '{strategy}' not found in registry. Available: {available:?}", ) })? .clone(); @@ -89,7 +143,7 @@ impl Execute for StrategyBuilder { }; let mut builder = - RaindexOrderBuilder::new_with_deployment(dotrain, settings, self.deployment.clone()) + RaindexOrderBuilder::new_with_deployment(dotrain, settings, deployment.clone()) .await .map_err(|err| { anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) @@ -125,7 +179,7 @@ impl Execute for StrategyBuilder { } let args = builder - .get_deployment_transaction_args(self.owner.clone()) + .get_deployment_transaction_args(owner.clone()) .await .map_err(|err| { anyhow::anyhow!( @@ -135,9 +189,11 @@ impl Execute for StrategyBuilder { })?; for approval in &args.approvals { + println!("# approve {}", approval.symbol); println!("{}:0x{}", approval.token, hex::encode(&approval.calldata)); } + println!("# deploy {strategy} order"); println!( "{}:0x{}", args.orderbook_address, @@ -145,6 +201,7 @@ impl Execute for StrategyBuilder { ); if let Some(meta_call) = &args.emit_meta_call { + println!("# emit strategy metadata"); println!("{}:0x{}", meta_call.to, hex::encode(&meta_call.calldata)); } @@ -194,6 +251,20 @@ mod tests { assert_eq!(map.get("key").unwrap(), "value=with=equals"); } + #[test] + fn parse_key_value_pairs_empty_key_fails() { + let args = vec!["=value".to_string()]; + let err = parse_key_value_pairs(&args).unwrap_err().to_string(); + assert!(err.contains("expected non-empty KEY"), "got: {err}"); + } + + #[test] + fn parse_key_value_pairs_whitespace_key_fails() { + let args = vec![" =value".to_string()]; + let err = parse_key_value_pairs(&args).unwrap_err().to_string(); + assert!(err.contains("expected non-empty KEY"), "got: {err}"); + } + #[test] fn parse_key_value_pairs_duplicate_key_fails() { let args = vec!["key=first".to_string(), "key=second".to_string()]; diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs new file mode 100644 index 0000000000..bc62fcc753 --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -0,0 +1,396 @@ +//! Select and Input widgets rendered directly to a writer. +//! The caller manages the alternate screen lifecycle. + +use anyhow::Result; +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; +use std::io::Write; + +const BOLD_UNDERLINE: &str = "\x1b[1;4m"; +const BOLD: &str = "\x1b[1m"; +const BOLD_CYAN: &str = "\x1b[1;36m"; +const DIM: &str = "\x1b[2m"; +const CYAN: &str = "\x1b[36m"; +const RESET: &str = "\x1b[0m"; + +pub struct SelectItem { + pub key: String, + pub description: String, +} + +/// Context shown above a prompt: prior selections and optional description. +pub struct SelectContext<'a> { + pub header_lines: &'a [String], + pub description: Option<&'a str>, +} + +impl<'a> SelectContext<'a> { + pub fn new(header_lines: &'a [String]) -> Self { + Self { + header_lines, + description: None, + } + } + + pub fn with_description(mut self, desc: &'a str) -> Self { + self.description = Some(desc); + self + } +} + +/// Word-wrap `text` to fit within `width` columns. Returns the number of lines. +fn wrap_lines(text: &str, width: usize) -> Vec { + if text.is_empty() || width == 0 { + return Vec::new(); + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current = word.to_string(); + } else if current.len() + 1 + word.len() <= width { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current = word.to_string(); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Write wrapped `text` with the given indent and ANSI style wrapper. +/// Returns number of lines written. +fn write_wrapped( + w: &mut impl Write, + text: &str, + indent: &str, + style: &str, + width: usize, +) -> Result { + let lines = wrap_lines(text, width); + for line in &lines { + write!(w, "{indent}{style}{line}{RESET}\r\n")?; + } + Ok(lines.len()) +} + +/// Write the header (prior selections) and return the number of rows used. +fn write_header(w: &mut impl Write, header_lines: &[String]) -> Result { + for line in header_lines { + write!(w, " {line}\r\n")?; + } + if !header_lines.is_empty() { + write!(w, "\r\n")?; + Ok(header_lines.len() + 1) + } else { + Ok(0) + } +} + +/// Write bold-underlined title + optional dim description. Returns rows used. +fn write_title( + w: &mut impl Write, + title: &str, + description: Option<&str>, + cols: usize, +) -> Result { + write!(w, " {BOLD_UNDERLINE}{title}{RESET}\r\n")?; + let mut rows = 1; + if let Some(desc) = description { + rows += write_wrapped(w, desc, " ", DIM, cols.saturating_sub(2))?; + } + write!(w, "\r\n")?; + Ok(rows + 1) +} + +/// Height (in rendered terminal rows) of a single select item. +fn item_height(item: &SelectItem, cols: usize) -> usize { + // " ❯ " prefix (4) + trailing margin (1) + let usable = cols.saturating_sub(5); + let desc_lines = if item.description.is_empty() { + 0 + } else { + wrap_lines(&item.description, usable).len() + }; + 1 + desc_lines + 1 // name + description + blank separator +} + +/// Count how many items fit starting at `offset`, given `max_rows` available. +fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize, cols: usize) -> usize { + let mut rows = 0; + let mut count = 0; + for item in items.iter().skip(offset) { + let h = item_height(item, cols); + if rows + h > max_rows { + break; + } + rows += h; + count += 1; + } + count.max(1) +} + +/// Text input rendered in the alt screen. +pub fn input( + w: &mut impl Write, + prompt: &str, + description: Option<&str>, + default: Option<&str>, + allow_empty: bool, + header_lines: &[String], +) -> Result { + let mut buffer = String::new(); + loop { + let (cols, _) = terminal::size()?; + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + write_header(w, header_lines)?; + write_title(w, prompt, description, cols as usize)?; + + write!(w, " > {CYAN}{buffer}{RESET}")?; + if buffer.is_empty() { + if let Some(d) = default { + write!(w, "{DIM}{d}{RESET}")?; + } + } + write!(w, "\x1b[?25h")?; // show cursor + w.flush()?; + + if let Event::Key(KeyEvent { + code, modifiers, .. + }) = event::read()? + { + match code { + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + anyhow::bail!("cancelled"); + } + KeyCode::Char(c) => buffer.push(c), + KeyCode::Backspace => { + buffer.pop(); + } + KeyCode::Enter => match (buffer.is_empty(), default, allow_empty) { + (true, Some(d), _) => return Ok(d.to_string()), + (true, None, true) => return Ok(String::new()), + (true, None, false) => {} // keep looping — require input + (false, _, _) => return Ok(buffer), + }, + KeyCode::Esc => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +/// Scrollable select list. +pub fn select( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + ctx: &SelectContext, +) -> Result { + if items.is_empty() { + anyhow::bail!("no items to select from"); + } + if items.len() == 1 { + return Ok(0); + } + + let mut selected = 0usize; + let mut scroll = 0usize; + + loop { + let (cols, rows) = terminal::size()?; + let cols = cols as usize; + let rows = rows as usize; + + let header_rows = render_select(w, title, items, selected, scroll, ctx)?; + let max_rows = rows.saturating_sub(header_rows + 2); + + if let Event::Key(KeyEvent { code, .. }) = event::read()? { + match code { + KeyCode::Up | KeyCode::Char('k') => { + if selected > 0 { + selected -= 1; + if selected < scroll { + scroll = selected; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if selected + 1 < items.len() { + selected += 1; + let fitting = count_items_fitting(items, scroll, max_rows, cols); + if selected >= scroll + fitting { + scroll += 1; + } + } + } + KeyCode::Enter => return Ok(selected), + KeyCode::Esc | KeyCode::Char('q') => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +/// Render the select list. Returns the number of rows used by the header +/// (everything above the items) so the caller can compute `max_rows`. +fn render_select( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + selected: usize, + scroll: usize, + ctx: &SelectContext, +) -> Result { + let (cols, rows) = terminal::size()?; + let cols = cols as usize; + let rows = rows as usize; + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + + let header_rows = write_header(w, ctx.header_lines)?; + let title_rows = write_title(w, title, ctx.description, cols)?; + let total_header = header_rows + title_rows; + let max_rows = rows.saturating_sub(total_header + 2); + + let mut rows_used = 0; + for (idx, item) in items.iter().enumerate().skip(scroll) { + let h = item_height(item, cols); + if rows_used + h > max_rows { + break; + } + + let is_selected = idx == selected; + let (prefix, style) = if is_selected { + ("❯ ", BOLD_CYAN) + } else { + (" ", BOLD) + }; + write!(w, " {prefix}{style}{}{RESET}\r\n", item.key)?; + if !item.description.is_empty() { + write_wrapped(w, &item.description, " ", DIM, cols.saturating_sub(5))?; + } + write!(w, "\r\n")?; + rows_used += h; + } + + // Scroll indicators + let xpos = cols.saturating_sub(3) as u16; + let ypos_bottom = rows.saturating_sub(2) as u16; + let ypos_hint = rows.saturating_sub(1) as u16; + if scroll > 0 { + execute!(w, cursor::MoveTo(xpos, total_header as u16))?; + write!(w, " ▲")?; + } + if scroll + count_items_fitting(items, scroll, max_rows, cols) < items.len() { + execute!(w, cursor::MoveTo(xpos, ypos_bottom))?; + write!(w, " ▼")?; + } + + // Bottom hint + execute!(w, cursor::MoveTo(0, ypos_hint))?; + write!(w, " {DIM}↑↓ navigate ⏎ select esc quit{RESET}")?; + + w.flush()?; + Ok(total_header) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_empty_text_returns_no_lines() { + assert!(wrap_lines("", 10).is_empty()); + } + + #[test] + fn wrap_short_text_returns_single_line() { + assert_eq!(wrap_lines("hello world", 20), vec!["hello world"]); + } + + #[test] + fn wrap_long_text_breaks_on_word_boundaries() { + let result = wrap_lines("one two three four five", 10); + assert_eq!(result, vec!["one two", "three four", "five"]); + } + + #[test] + fn wrap_very_long_word_goes_on_own_line() { + let result = wrap_lines("a supercalifragilistic b", 10); + assert_eq!(result, vec!["a", "supercalifragilistic", "b"]); + } + + #[test] + fn wrap_zero_width_returns_no_lines() { + assert!(wrap_lines("hello", 0).is_empty()); + } + + #[test] + fn item_height_with_empty_description() { + let item = SelectItem { + key: "key".into(), + description: String::new(), + }; + // name line + blank separator = 2 + assert_eq!(item_height(&item, 80), 2); + } + + #[test] + fn item_height_with_description_wraps() { + let item = SelectItem { + key: "key".into(), + description: "one two three four five six seven eight nine ten".into(), + }; + // usable width = 80 - 5 = 75 — fits on one line + assert_eq!(item_height(&item, 80), 3); + } + + #[test] + fn item_height_with_description_narrow_terminal() { + let item = SelectItem { + key: "key".into(), + description: "one two three four five six seven eight nine ten".into(), + }; + // usable = 20 - 5 = 15 — "one two three" (13), "four five six" (13), etc. + let h = item_height(&item, 20); + assert!(h > 3, "expected wrapped description, got height {h}"); + } + + #[test] + fn count_items_fitting_respects_max_rows() { + let items = vec![ + SelectItem { + key: "a".into(), + description: String::new(), + }, + SelectItem { + key: "b".into(), + description: String::new(), + }, + SelectItem { + key: "c".into(), + description: String::new(), + }, + ]; + // Each item = 2 rows; 5 rows max fits 2 items (4 rows), not 3 (6 rows) + assert_eq!(count_items_fitting(&items, 0, 5, 80), 2); + } + + #[test] + fn count_items_fitting_always_returns_at_least_one() { + let items = vec![SelectItem { + key: "a".into(), + description: "very long description that will wrap many times".into(), + }]; + // Even with max_rows = 1, we return 1 to avoid an empty list + assert_eq!(count_items_fitting(&items, 0, 1, 80), 1); + } +} diff --git a/crates/cli/src/commands/strategy_builder/tokens.rs b/crates/cli/src/commands/strategy_builder/tokens.rs new file mode 100644 index 0000000000..91d323440d --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/tokens.rs @@ -0,0 +1,167 @@ +//! `--tokens` mode: lists all tokens available for `--select-token` on a +//! given strategy + deployment, as markdown. + +use anyhow::Result; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::fmt::Write; + +pub async fn run_tokens(registry_url: &str, strategy: &str, deployment: &str) -> Result<()> { + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let dotrain = registry + .orders() + .0 + .get(strategy) + .ok_or_else(|| { + let available = registry.get_order_keys().unwrap_or_default(); + anyhow::anyhow!("strategy '{strategy}' not found. Available: {available:?}") + })? + .clone(); + + let settings = registry_settings(®istry); + + let builder = + RaindexOrderBuilder::new_with_deployment(dotrain, settings, deployment.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let tokens = builder + .get_all_tokens(None) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut out = String::new(); + writeln!(out, "# Available tokens — `{strategy}` / `{deployment}`")?; + writeln!(out)?; + + if tokens.is_empty() { + writeln!(out, "_No tokens registered for this deployment._")?; + } else { + writeln!( + out, + "{} tokens. Use any address as the value of a `--select-token KEY=
` flag.", + tokens.len() + )?; + writeln!(out)?; + writeln!(out, "| Symbol | Name | Address | Decimals |")?; + writeln!(out, "|--------|------|---------|----------|")?; + for token in tokens { + writeln!( + out, + "| `{}` | {} | `{}` | {} |", + token.symbol, token.name, token.address, token.decimals + )?; + } + } + + println!("{out}"); + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn lists_tokens_for_a_deployment() { + let server = httpmock::MockServer::start(); + + let settings = r#"version: 4 +networks: + base: + rpcs: + - https://base-rpc.publicnode.com + chain-id: 8453 + network-id: 8453 + currency: ETH +orderbooks: + base: + address: 0xe522cB4a5fCb2eb31a52Ff41a4653d85A4fd7C9D + network: base +deployers: + base: + address: 0xd905B56949284Bb1d28eeFC05be78Af69cCf3668 + network: base +tokens: + test-usdc: + network: base + address: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 + decimals: 6 + label: USD Coin + symbol: USDC +"#; + + let strategy = r#"version: 4 +orders: + base: + orderbook: base + inputs: + - token: token1 + outputs: + - token: token2 +scenarios: + base: + orderbook: base + runs: 1 + bindings: {} +deployments: + base: + order: base + scenario: base +builder: + name: Test + description: Test + short-description: Test + deployments: + base: + name: Base + description: Test + deposits: [] + fields: [] + select-tokens: + - key: token1 + - key: token2 +--- +#calculate-io +max-output: max-positive-value(), +io: 1; +#handle-io +:; +#handle-add-order +:; +"#; + + let settings_url = format!("{}/settings.yaml", server.base_url()); + let strategy_url = format!("{}/test.rain", server.base_url()); + let registry_url = format!("{}/registry", server.base_url()); + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/registry"); + then.status(200) + .body(format!("{settings_url}\ntest {strategy_url}\n")); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/settings.yaml"); + then.status(200).body(settings); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/test.rain"); + then.status(200).body(strategy); + }); + + let result = run_tokens(®istry_url, "test", "base").await; + assert!(result.is_ok(), "tokens failed: {result:?}"); + } +} diff --git a/crates/settings/src/yaml/context.rs b/crates/settings/src/yaml/context.rs index c50cf35b7b..425b0b90f3 100644 --- a/crates/settings/src/yaml/context.rs +++ b/crates/settings/src/yaml/context.rs @@ -360,13 +360,25 @@ impl Context { let var_start = start + var_start; if let Some(var_end) = result[var_start..].find('}') { let var_end = var_start + var_end + 1; - let var = &result[var_start + 2..var_end - 1]; - let replacement = match self.resolve_path(var) { + let inner = &result[var_start + 2..var_end - 1]; + + // Split on `||` to extract an optional fallback string literal. + // Syntax: ${path} or ${path || 'fallback'} or ${path || "fallback"}. + // The fallback is used when the path resolves to a missing token field + // (i.e. select-token not yet selected). + let (path, fallback) = parse_path_and_fallback(inner); + + let replacement = match self.resolve_path(path) { Ok(value) => Some(value), + Err(ContextError::PropertyNotFound(property)) + if fallback.is_some() && property == "token" => + { + Some(fallback.unwrap().to_string()) + } Err(ContextError::PropertyNotFound(property)) if allow_select_tokens && property == "token" - && self.select_token_key_for_path(var).is_some() => + && self.select_token_key_for_path(path).is_some() => { None } @@ -388,6 +400,33 @@ impl Context { } } +/// Split a `${...}` body into `(path, optional fallback literal)`. +/// Recognised forms: +/// path +/// path || 'fallback' +/// path || "fallback" +/// Whitespace around `||` and within the literal bounds is trimmed. +/// If the body doesn't match the fallback form, returns `(body_trimmed, None)`. +fn parse_path_and_fallback(body: &str) -> (&str, Option<&str>) { + let Some(or_pos) = body.find("||") else { + return (body.trim(), None); + }; + let (left, right) = body.split_at(or_pos); + let path = left.trim(); + let right = right[2..].trim(); + + // Strip matching single or double quotes around the fallback. + let stripped = right + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| right.strip_prefix('"').and_then(|s| s.strip_suffix('"'))); + + match stripped { + Some(literal) => (path, Some(literal)), + None => (body.trim(), None), // malformed — treat whole thing as path + } +} + #[cfg(test)] mod tests { use super::*; @@ -556,6 +595,88 @@ mod tests { ); } + #[test] + fn test_interpolate_fallback_for_unresolved_token() { + // In strict mode with a select-token not yet selected, a fallback literal + // should be substituted in place of the token path. + let order = setup_select_token_order(); + let mut context = Context::new(); + context.add_order(order.clone()); + context.add_select_tokens(vec!["token1".to_string()]); + + let out = context + .interpolate("${order.inputs.0.token.symbol || 'input token'}") + .unwrap(); + assert_eq!(out, "input token"); + + // Double quotes also supported. + let out = context + .interpolate(r#"${order.inputs.0.token.symbol || "input token"}"#) + .unwrap(); + assert_eq!(out, "input token"); + + // Whitespace around || is tolerated. + let out = context + .interpolate("${order.inputs.0.token.symbol||'x'}") + .unwrap(); + assert_eq!(out, "x"); + + // Mixed in a surrounding template string (using only inputs.0 since + // the select-token fixture only has one input). + let out = context + .interpolate("${order.inputs.0.token.symbol || 'buy'} at ${order.inputs.0.token.symbol || 'pair'}") + .unwrap(); + assert_eq!(out, "buy at pair"); + } + + #[test] + fn test_interpolate_fallback_not_used_when_resolved() { + // If the path resolves, the fallback is ignored. + let mut context = Context::new(); + let order = setup_test_order_with_vault_id(); + context.add_order(order); + + let out = context + .interpolate("${order.inputs.0.token.symbol || 'fallback'}") + .unwrap(); + // The test token has symbol None, so .symbol still returns empty string or errors. + // Either way, the fallback only kicks in on PropertyNotFound("token"), not on + // a present-but-empty symbol. We just check the fallback isn't blindly applied. + assert_ne!(out, "fallback"); + } + + #[test] + fn test_interpolate_fallback_not_applied_to_non_token_errors() { + // If the path fails for some reason other than missing token, the fallback + // should NOT be applied — the error should propagate. + let order = setup_select_token_order(); + let mut context = Context::new(); + context.add_order(order); + context.add_select_tokens(vec!["token1".to_string()]); + + let err = context + .interpolate("${order.inputs.0.vault-id || 'default'}") + .unwrap_err(); + assert_eq!(err, ContextError::PropertyNotFound("vault-id".to_string())); + } + + #[test] + fn test_interpolate_malformed_fallback_is_treated_as_path() { + // ${x || foo} (no quotes) isn't a valid fallback; the body is treated as a + // path. It'll fail to resolve since the path is nonsense. + let mut context = Context::new(); + context.add_order(setup_test_order_with_vault_id()); + + let err = context.interpolate("${bogus || foo}").unwrap_err(); + // Just assert it errors rather than silently substituting. + assert!(matches!( + err, + ContextError::InvalidPath(_) + | ContextError::PropertyNotFound(_) + | ContextError::NoOrder + )); + } + #[test] fn test_context_no_order() { let context = Context::new();