From 50d4083118efd52c4994ba081871265b3ea4bc1b Mon Sep 17 00:00:00 2001 From: hardyjosh <1190022+hardyjosh@users.noreply.github.com> Date: Mon, 11 May 2026 12:31:29 +0000 Subject: [PATCH] feat: add strategy-builder CLI subcommand (#2544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation The Raindex orderbook protocol has a rich GUI builder flow in the webapp for configuring and deploying strategies. There is currently no way to do the same from a terminal or from a non-interactive agent — the webapp is the only entry point. That forces anyone automating a deployment (CI, scripts, AI agents) to either drive the browser or hand-roll the calldata. ## Solution Add a `strategy-builder` subcommand to the raindex CLI that generates deployment calldata from a remote registry strategy. ``` raindex strategy-builder \ --registry \ --strategy \ --deployment \ --owner <0x-address> \ [--select-token KEY=ADDRESS ...] \ [--set-field BINDING=VALUE ...] \ [--set-deposit TOKEN=AMOUNT ...] ``` Outputs one `
:` line per transaction on stdout — approvals first, then the deployment multicall, then optional metaboard meta emission. Each line is one signable transaction. Implementation reuses `RaindexOrderBuilder` from the common crate (same object the webapp drives) and `DotrainRegistry` from the js_api crate, so the CLI and webapp use identical resolution semantics. Follow-up PRs in this stack add `--interactive` (#2546), `--tokens` (#2549), the template-fallback operator (#2551), and `--describe` (#2548). ## Checks - [x] made this PR as small as possible - [x] unit-tested any new functionality - [x] linked any relevant issues or PRs - [ ] included screenshots (if this involves a front-end change) ## Summary by CodeRabbit * **New Features** * Added `strategy-builder` command to the CLI for generating deployment calldata from registry-based strategies. The command accepts required parameters for registry URL, strategy identifier, deployment address, and owner. Users can further customize strategy deployments with repeatable options to set field bindings, select tokens, and configure deposit amounts, enabling streamlined deployment workflows. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/rainlanguage/raindex/pull/2544) --- crates/cli/Cargo.toml | 1 + crates/cli/src/commands/mod.rs | 4 +- crates/cli/src/commands/strategy_builder.rs | 222 ++++++++++++++++++++ crates/cli/src/lib.rs | 9 +- 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 crates/cli/src/commands/strategy_builder.rs diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 69e9e44b46..66a642500d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,6 +14,7 @@ alloy = { workspace = true } rain_orderbook_subgraph_client = { workspace = true } rain_orderbook_bindings = { workspace = true } rain_orderbook_common = { workspace = true } +rain_orderbook_js_api = { path = "../js_api" } rain_orderbook_app_settings = { workspace = true } rain_orderbook_quote = { workspace = true } anyhow = { workspace = true } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b0c3ac7320..2710ea74d3 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,11 +2,13 @@ mod chart; pub mod local_db; mod order; mod quote; +pub mod strategy_builder; mod subgraph; mod trade; mod vault; mod words; pub use self::{ - chart::Chart, order::Order, subgraph::Subgraph, trade::Trade, vault::Vault, words::Words, + chart::Chart, order::Order, strategy_builder::StrategyBuilder, subgraph::Subgraph, + trade::Trade, vault::Vault, words::Words, }; diff --git a/crates/cli/src/commands/strategy_builder.rs b/crates/cli/src/commands/strategy_builder.rs new file mode 100644 index 0000000000..6add4f20ea --- /dev/null +++ b/crates/cli/src/commands/strategy_builder.rs @@ -0,0 +1,222 @@ +use crate::execute::Execute; +use alloy::primitives::hex; +use anyhow::Result; +use clap::Parser; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::collections::HashMap; + +#[derive(Parser, Clone)] +pub struct StrategyBuilder { + #[arg( + long, + help = "Registry URL (text file: settings URL on line 1, then 'key url' per order)" + )] + registry: String, + + #[arg(long, help = "Order/strategy key from the registry")] + strategy: String, + + #[arg(long, help = "Deployment key within the strategy")] + deployment: String, + + #[arg(long, help = "Order owner address")] + owner: String, + + #[arg( + long = "set-field", + value_name = "BINDING=VALUE", + help = "Set a field binding value (repeatable)" + )] + set_fields: Vec, + + #[arg( + long = "select-token", + value_name = "KEY=ADDRESS", + help = "Select a token for a slot (repeatable)" + )] + select_tokens: Vec, + + #[arg( + long = "set-deposit", + value_name = "TOKEN=AMOUNT", + help = "Set a deposit amount (repeatable)" + )] + set_deposits: Vec, +} + +fn parse_key_value_pairs(args: &[String]) -> Result> { + let mut map = HashMap::new(); + for arg in args { + 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}"); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + +impl Execute for StrategyBuilder { + async fn execute(&self) -> Result<()> { + let registry = DotrainRegistry::new(self.registry.clone()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let dotrain = registry + .orders() + .0 + .get(&self.strategy) + .ok_or_else(|| { + let mut available = registry.get_order_keys().unwrap_or_default(); + available.sort(); + anyhow::anyhow!( + "strategy '{}' not found in registry. Available: {:?}", + self.strategy, + available + ) + })? + .clone(); + + let settings = { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } + }; + + let mut builder = + RaindexOrderBuilder::new_with_deployment(dotrain, settings, self.deployment.clone()) + .await + .map_err(|err| { + anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) + })?; + + let fields = parse_key_value_pairs(&self.set_fields)?; + for (binding, value) in &fields { + builder + .set_field_value(binding.clone(), value.clone()) + .map_err(|err| { + anyhow::anyhow!("failed to set field '{binding}': {}", err.to_readable_msg()) + })?; + } + + let tokens = parse_key_value_pairs(&self.select_tokens)?; + for (key, address) in &tokens { + builder + .set_select_token(key.clone(), address.clone()) + .await + .map_err(|err| { + anyhow::anyhow!("failed to select token '{key}': {}", err.to_readable_msg()) + })?; + } + + let deposits = parse_key_value_pairs(&self.set_deposits)?; + for (token, amount) in &deposits { + builder + .set_deposit(token.clone(), amount.clone()) + .await + .map_err(|err| { + anyhow::anyhow!("failed to set deposit '{token}': {}", err.to_readable_msg()) + })?; + } + + let args = builder + .get_deployment_transaction_args(self.owner.clone()) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to generate deployment calldata: {}", + err.to_readable_msg() + ) + })?; + + for approval in &args.approvals { + println!("{}:0x{}", approval.token, hex::encode(&approval.calldata)); + } + + println!( + "{}:0x{}", + args.orderbook_address, + hex::encode(&args.deployment_calldata) + ); + + if let Some(meta_call) = &args.emit_meta_call { + println!("{}:0x{}", meta_call.to, hex::encode(&meta_call.calldata)); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn verify_cli() { + StrategyBuilder::command().debug_assert(); + } + + #[test] + fn parse_key_value_pairs_valid() { + let args = vec![ + "max-spread=0.002".to_string(), + "oracle-key=ETH/USD".to_string(), + ]; + let map = parse_key_value_pairs(&args).unwrap(); + assert_eq!(map.get("max-spread").unwrap(), "0.002"); + assert_eq!(map.get("oracle-key").unwrap(), "ETH/USD"); + } + + #[test] + fn parse_key_value_pairs_missing_equals() { + let args = vec!["no-equals".to_string()]; + let result = parse_key_value_pairs(&args); + assert!(result.is_err()); + } + + #[test] + fn parse_key_value_pairs_empty() { + let args: Vec = vec![]; + let map = parse_key_value_pairs(&args).unwrap(); + assert!(map.is_empty()); + } + + #[test] + fn parse_key_value_pairs_value_with_equals() { + let args = vec!["key=value=with=equals".to_string()]; + let map = parse_key_value_pairs(&args).unwrap(); + 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()]; + let err = parse_key_value_pairs(&args).unwrap_err().to_string(); + assert!(err.contains("duplicate key: key"), "got: {err}"); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index eba89a4991..af58fa1405 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,4 +1,4 @@ -use crate::commands::{Chart, Order, Subgraph, Trade, Vault, Words}; +use crate::commands::{Chart, Order, StrategyBuilder, Subgraph, Trade, Vault, Words}; use crate::execute::Execute; use anyhow::Result; use clap::Subcommand; @@ -34,6 +34,12 @@ pub enum Orderbook { #[command(name = "local-db", subcommand)] LocalDb(LocalDbCommands), + + #[command( + name = "strategy-builder", + about = "Generate deployment calldata from a registry strategy" + )] + StrategyBuilder(StrategyBuilder), } impl Orderbook { @@ -47,6 +53,7 @@ impl Orderbook { Orderbook::Subgraph(subgraph) => subgraph.execute().await, Orderbook::Words(words) => words.execute().await, Orderbook::LocalDb(local_db) => local_db.execute().await, + Orderbook::StrategyBuilder(strategy_builder) => strategy_builder.execute().await, } } }