Skip to main content

cargo_tangle/command/
tangle.rs

1use std::cell::OnceCell;
2use std::fs;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use alloy_primitives::Address;
7use blueprint_client_tangle::{TangleClient, TangleClientConfig, TangleSettings};
8use blueprint_runner::tangle::config::TangleProtocolSettings;
9use blueprint_testing_utils::anvil::{
10    SeededTangleTestnet, TangleHarness,
11    tangle::{LOCAL_SERVICE_ID, insert_default_operator_key},
12};
13use clap::Args;
14use color_eyre::eyre::{Result, eyre};
15use tempfile::TempDir;
16use url::Url;
17
18use crate::command::run::tangle::RunOpts;
19use crate::workspace::{Network, TangleWorkspace};
20use blueprint_manager::config::SourceType;
21
22/// Shared CLI arguments for connecting to the Tangle stack.
23///
24/// All fields are optional at the CLI level. A value is resolved from (in order):
25///
26///   1. The command-line flag.
27///   2. The network entry in `.tangle.toml` (see [`crate::workspace`]).
28///   3. For RPC URLs and keystore path only: a sensible devnet default.
29///
30/// This means `cargo-tangle jobs submit --job 0 --payload-hex ...` works with no
31/// other flags when a `.tangle.toml` is present — contract addresses and endpoints
32/// come from the workspace.
33#[derive(Args, Debug, Clone, Default)]
34pub struct TangleClientArgs {
35    /// HTTP RPC endpoint.
36    #[arg(long, value_name = "URL")]
37    pub http_rpc_url: Option<Url>,
38    /// WebSocket RPC endpoint.
39    #[arg(long, value_name = "URL")]
40    pub ws_rpc_url: Option<Url>,
41    /// Path to the keystore directory.
42    #[arg(long)]
43    pub keystore_path: Option<PathBuf>,
44    /// Tangle contract address.
45    #[arg(long, value_name = "ADDRESS")]
46    pub tangle_contract: Option<String>,
47    /// Staking contract address.
48    #[arg(long = "staking-contract", value_name = "ADDRESS")]
49    pub staking_contract: Option<String>,
50    /// Optional status registry contract address.
51    #[arg(long, value_name = "ADDRESS")]
52    pub status_registry_contract: Option<String>,
53    /// Override the active network from `.tangle.toml`.
54    #[arg(long, value_name = "NAME")]
55    pub network: Option<String>,
56    /// Memoised output of `resolve()` so multiple accessors don't re-read
57    /// `.tangle.toml`. Also prevents inconsistency if the file changes
58    /// mid-command.
59    #[clap(skip)]
60    #[doc(hidden)]
61    resolved: OnceCell<Resolved>,
62}
63
64/// Fully-resolved parameters after merging CLI + workspace + defaults.
65#[derive(Debug, Clone)]
66struct Resolved {
67    http_rpc_url: Url,
68    ws_rpc_url: Url,
69    keystore_path: PathBuf,
70    tangle: Address,
71    staking: Address,
72    status: Address,
73}
74
75impl TangleClientArgs {
76    fn resolve(&self) -> Result<&Resolved> {
77        if let Some(r) = self.resolved.get() {
78            return Ok(r);
79        }
80        let r = self.resolve_fresh()?;
81        // Ignore any race — first writer wins, we return whatever's there.
82        let _ = self.resolved.set(r);
83        Ok(self.resolved.get().expect("just set"))
84    }
85
86    fn resolve_fresh(&self) -> Result<Resolved> {
87        let ws = TangleWorkspace::discover()?;
88        let net: Option<&Network> = match (&self.network, ws.as_ref()) {
89            (Some(name), Some(ws)) => Some(ws.network(name)?),
90            (None, Some(ws)) => Some(ws.active_network()?),
91            _ => None,
92        };
93
94        let http_rpc_url = self
95            .http_rpc_url
96            .clone()
97            .or_else(|| net.map(|n| n.http_rpc_url.clone()))
98            .unwrap_or_else(|| Url::parse("http://127.0.0.1:8545").expect("literal url"));
99
100        // NB: historical default was port 8546 (geth convention); current
101        // Anvil binds http + ws on the same port. We default to 8545 for the
102        // Anvil case but document loudly for anyone relying on the old default.
103        let ws_rpc_url = self
104            .ws_rpc_url
105            .clone()
106            .or_else(|| net.map(|n| n.ws_rpc_url.clone()))
107            .unwrap_or_else(|| Url::parse("ws://127.0.0.1:8545").expect("literal url"));
108
109        let keystore_path = self
110            .keystore_path
111            .clone()
112            .or_else(|| ws.as_ref().and_then(|w| w.defaults.keystore_path.clone()))
113            .unwrap_or_else(|| PathBuf::from("./keystore"));
114
115        let tangle = match (&self.tangle_contract, net) {
116            (Some(s), _) => parse_address(s, "TANGLE_CONTRACT")?,
117            (None, Some(n)) => n.tangle_contract,
118            (None, None) => {
119                return Err(missing_addr_err("tangle_contract", "TANGLE_CONTRACT"));
120            }
121        };
122        let staking = match (&self.staking_contract, net) {
123            (Some(s), _) => parse_address(s, "STAKING_CONTRACT")?,
124            (None, Some(n)) => n.staking_contract,
125            (None, None) => {
126                return Err(missing_addr_err("staking_contract", "STAKING_CONTRACT"));
127            }
128        };
129        let status = match (&self.status_registry_contract, net) {
130            (Some(s), _) => parse_address(s, "STATUS_REGISTRY_CONTRACT")?,
131            (None, Some(n)) => n.status_registry_contract.unwrap_or(Address::ZERO),
132            (None, None) => Address::ZERO,
133        };
134
135        Ok(Resolved {
136            http_rpc_url,
137            ws_rpc_url,
138            keystore_path,
139            tangle,
140            staking,
141            status,
142        })
143    }
144
145    /// Build a client config using the provided blueprint/service identifiers.
146    pub fn client_config(
147        &self,
148        blueprint_id: u64,
149        service_id: Option<u64>,
150    ) -> Result<TangleClientConfig> {
151        let r = self.resolve()?;
152        let settings = TangleSettings {
153            blueprint_id,
154            service_id,
155            tangle_contract: r.tangle,
156            staking_contract: r.staking,
157            status_registry_contract: r.status,
158        };
159
160        Ok(TangleClientConfig::new(
161            r.http_rpc_url.clone(),
162            r.ws_rpc_url.clone(),
163            r.keystore_path.display().to_string(),
164            settings,
165        ))
166    }
167
168    /// Connect a `TangleClient` using these arguments.
169    pub async fn connect(
170        &self,
171        blueprint_id: u64,
172        service_id: Option<u64>,
173    ) -> Result<TangleClient> {
174        let config = self.client_config(blueprint_id, service_id)?;
175        TangleClient::new(config)
176            .await
177            .map_err(|e| eyre!(e.to_string()))
178    }
179
180    /// Resolved keystore path (CLI > workspace default > `./keystore`).
181    pub fn keystore_path(&self) -> Result<PathBuf> {
182        Ok(self.resolve()?.keystore_path.clone())
183    }
184
185    /// Resolved HTTP RPC URL.
186    pub fn http_rpc_url(&self) -> Result<Url> {
187        Ok(self.resolve()?.http_rpc_url.clone())
188    }
189
190    /// Resolved WebSocket RPC URL.
191    pub fn ws_rpc_url(&self) -> Result<Url> {
192        Ok(self.resolve()?.ws_rpc_url.clone())
193    }
194}
195
196fn missing_addr_err(field: &str, _env_name: &str) -> color_eyre::eyre::Report {
197    eyre!(
198        "missing {field}: pass --{} or define it in `.tangle.toml`. Run `cargo-tangle dev up` to auto-generate a workspace for local development.",
199        field.replace('_', "-")
200    )
201}
202
203#[cfg(test)]
204impl TangleClientArgs {
205    /// Construct a fully-specified args bundle for unit/integration tests.
206    pub fn for_testing(
207        http_rpc_url: Url,
208        ws_rpc_url: Url,
209        keystore_path: PathBuf,
210        tangle_contract: impl Into<String>,
211        staking_contract: impl Into<String>,
212        status_registry_contract: Option<String>,
213    ) -> Self {
214        Self {
215            http_rpc_url: Some(http_rpc_url),
216            ws_rpc_url: Some(ws_rpc_url),
217            keystore_path: Some(keystore_path),
218            tangle_contract: Some(tangle_contract.into()),
219            staking_contract: Some(staking_contract.into()),
220            status_registry_contract,
221            network: None,
222            resolved: OnceCell::new(),
223        }
224    }
225}
226
227/// Parse an address string with a helpful label.
228pub fn parse_address(value: &str, name: &str) -> Result<Address> {
229    Address::from_str(value).map_err(|_| eyre!("Invalid {name}: {value}"))
230}
231
232/// Preferred spawning strategy for local runs.
233#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
234pub enum SpawnMethod {
235    #[default]
236    Vm,
237    Native,
238    Container,
239}
240
241impl SpawnMethod {
242    #[must_use]
243    pub fn preferred_source(self) -> SourceType {
244        match self {
245            SpawnMethod::Container => SourceType::Container,
246            SpawnMethod::Vm | SpawnMethod::Native => SourceType::Native,
247        }
248    }
249
250    #[must_use]
251    pub fn use_vm(self) -> bool {
252        matches!(self, SpawnMethod::Vm)
253    }
254}
255
256/// Preferred artifact source for the manager.
257#[derive(clap::ValueEnum, Clone, Copy, Debug)]
258pub enum PreferredSourceArg {
259    Native,
260    Container,
261    Wasm,
262}
263
264impl From<PreferredSourceArg> for SourceType {
265    fn from(value: PreferredSourceArg) -> Self {
266        match value {
267            PreferredSourceArg::Native => SourceType::Native,
268            PreferredSourceArg::Container => SourceType::Container,
269            PreferredSourceArg::Wasm => SourceType::Wasm,
270        }
271    }
272}
273
274/// In-memory devnet stack backed by the seeded Anvil harness.
275#[derive(Debug)]
276pub struct DevnetStack {
277    harness: SeededTangleTestnet,
278    _temp_dir: TempDir,
279    keystore_dir: PathBuf,
280    data_dir: PathBuf,
281}
282
283impl DevnetStack {
284    /// Spawn a deterministic local stack.
285    pub async fn spawn(include_anvil_logs: bool) -> Result<Self> {
286        let harness = TangleHarness::builder()
287            .include_anvil_logs(include_anvil_logs)
288            .spawn()
289            .await
290            .map_err(|e| eyre!(e.to_string()))?;
291
292        let temp_dir =
293            TempDir::new().map_err(|e| eyre!("failed to create temporary workspace: {e}"))?;
294        let keystore_dir = temp_dir.path().join("keystore");
295        ensure_dir(&keystore_dir)?;
296        let data_dir = temp_dir.path().join("data");
297        ensure_dir(&data_dir)?;
298
299        seed_operator_keystore(&keystore_dir)?;
300
301        Ok(Self {
302            harness,
303            _temp_dir: temp_dir,
304            keystore_dir,
305            data_dir,
306        })
307    }
308
309    /// HTTP RPC endpoint for the harness.
310    #[must_use]
311    pub fn http_rpc_url(&self) -> Url {
312        self.harness.http_endpoint().clone()
313    }
314
315    /// WebSocket RPC endpoint for the harness.
316    #[must_use]
317    pub fn ws_rpc_url(&self) -> Url {
318        self.harness.ws_endpoint().clone()
319    }
320
321    /// Keystore path seeded with the operator key.
322    #[must_use]
323    pub fn keystore_path(&self) -> String {
324        self.keystore_dir.display().to_string()
325    }
326
327    /// Data directory for the stack.
328    #[must_use]
329    pub fn data_dir(&self) -> PathBuf {
330        self.data_dir.clone()
331    }
332
333    /// Default service identifier baked into the harness.
334    #[must_use]
335    pub fn default_service_id(&self) -> u64 {
336        LOCAL_SERVICE_ID
337    }
338
339    /// Addresses of the deployed contracts.
340    #[must_use]
341    pub fn tangle_contract(&self) -> Address {
342        self.harness.tangle_contract
343    }
344
345    #[must_use]
346    pub fn staking_contract(&self) -> Address {
347        self.harness.staking_contract
348    }
349
350    #[must_use]
351    pub fn status_registry_contract(&self) -> Address {
352        self.harness.status_registry_contract
353    }
354
355    /// Consume the stack and shut down resources. Cleanup happens via the
356    /// default Drop impls of `TangleHarness` (stops anvil) and `TempDir`
357    /// (removes the scratch dir).
358    pub async fn shutdown(self) {
359        drop(self);
360    }
361}
362
363pub fn run_opts_from_stack(
364    stack: &DevnetStack,
365    settings: &TangleProtocolSettings,
366    allow_unchecked_attestations: bool,
367    method: SpawnMethod,
368) -> RunOpts {
369    RunOpts {
370        http_rpc_url: stack.http_rpc_url(),
371        ws_rpc_url: stack.ws_rpc_url(),
372        blueprint_id: settings.blueprint_id,
373        service_id: settings.service_id.or(Some(stack.default_service_id())),
374        tangle_contract: stack.tangle_contract(),
375        staking_contract: stack.staking_contract(),
376        status_registry_contract: stack.status_registry_contract(),
377        keystore_path: stack.keystore_path(),
378        data_dir: Some(stack.data_dir()),
379        allow_unchecked_attestations,
380        registration_mode: false,
381        registration_capture_only: false,
382        preferred_source: method.preferred_source(),
383        use_vm: method.use_vm(),
384        dry_run: false,
385        shutdown_after: None,
386    }
387}
388
389fn ensure_dir(path: &PathBuf) -> Result<()> {
390    if !path.exists() {
391        fs::create_dir_all(path)?;
392    }
393    Ok(())
394}
395
396fn seed_operator_keystore(path: &PathBuf) -> Result<()> {
397    let keystore =
398        blueprint_keystore::Keystore::new(blueprint_keystore::KeystoreConfig::new().fs_root(path))?;
399    insert_default_operator_key(&keystore).map_err(|e| eyre!(e.to_string()))?;
400    Ok(())
401}