Skip to main content

cargo_tangle/command/deploy/
tangle.rs

1use crate::command::deploy::definition::{
2    BinaryArtifactSpec, BlueprintDefinitionInput, DefinitionOverrides, FetcherKind,
3    GithubArtifactSpec, NativeSourceOverride, RemoteArtifactSpec, SourceSummary,
4    SourceSummaryDetails, load_blueprint_definition,
5};
6use crate::command::run::tangle::run_blueprint;
7use crate::command::signer::KEYSTORE_PATH_ENV;
8use crate::command::tangle::{DevnetStack, SpawnMethod, parse_address, run_opts_from_stack};
9use crate::settings::load_protocol_settings;
10use alloy_primitives::Address;
11use blueprint_client_tangle::{
12    TangleClient, TangleClientConfig, TangleSettings, TransactionResult,
13};
14use blueprint_runner::config::Protocol;
15use blueprint_runner::tangle::config::TangleProtocolSettings;
16use clap::{Args, ValueEnum};
17use color_eyre::eyre::{Result, eyre};
18use std::env;
19use std::fmt;
20use std::path::PathBuf;
21use std::time::Duration;
22use url::Url;
23
24#[derive(Args, Debug, Clone)]
25pub struct TangleDeployArgs {
26    /// Target network for the deployment.
27    #[arg(long, value_enum, default_value_t = DeploymentNetwork::Devnet)]
28    pub network: DeploymentNetwork,
29    /// Optional settings file with Tangle EVM configuration.
30    #[arg(long, value_name = "FILE", default_value = "./settings.env")]
31    pub settings_file: PathBuf,
32    /// Stream Anvil stdout/stderr for debugging.
33    #[arg(long)]
34    pub include_anvil_logs: bool,
35    /// Allow unchecked attestations when running the manager.
36    #[arg(long)]
37    pub allow_unchecked_attestations: bool,
38    /// Preferred runtime for the service.
39    #[arg(long, value_enum, default_value_t = SpawnMethod::Vm)]
40    pub spawn_method: SpawnMethod,
41    /// Auto-shutdown the devnet run after the specified number of seconds.
42    #[arg(long, value_name = "SECONDS")]
43    pub exit_after_seconds: Option<u64>,
44    /// Path to the blueprint definition file (JSON/YAML/TOML) when targeting non-devnet networks.
45    #[arg(long, value_name = "FILE")]
46    pub definition: Option<PathBuf>,
47    /// Override the first native source with CLI-provided metadata.
48    #[arg(long, value_enum)]
49    pub artifact_source: Option<NativeArtifactSource>,
50    /// Entry point for the overridden native source.
51    #[arg(long)]
52    pub artifact_entrypoint: Option<String>,
53    /// Owner for GitHub artifact overrides.
54    #[arg(long)]
55    pub github_owner: Option<String>,
56    /// Repository for GitHub artifact overrides.
57    #[arg(long)]
58    pub github_repo: Option<String>,
59    /// Tag for GitHub artifact overrides.
60    #[arg(long)]
61    pub github_tag: Option<String>,
62    /// Distribution manifest URL for HTTP/IPFS artifact overrides.
63    #[arg(long)]
64    pub remote_dist_url: Option<String>,
65    /// Archive URL for HTTP/IPFS artifact overrides.
66    #[arg(long)]
67    pub remote_archive_url: Option<String>,
68    /// Binary descriptors in the form NAME:ARCH:OS:SHA256[:BLAKE3].
69    #[arg(long = "artifact-binary", value_name = "NAME:ARCH:OS:SHA256[:BLAKE3]")]
70    pub artifact_binaries: Vec<String>,
71    /// Override the HTTP RPC endpoint (non-devnet).
72    #[arg(long)]
73    pub http_rpc_url: Option<Url>,
74    /// Override the WebSocket RPC endpoint (non-devnet).
75    #[arg(long)]
76    pub ws_rpc_url: Option<Url>,
77    /// Override the keystore path (non-devnet).
78    #[arg(long)]
79    pub keystore_path: Option<PathBuf>,
80    /// Override the Tangle contract address (non-devnet).
81    #[arg(long)]
82    pub tangle_contract: Option<String>,
83    /// Override the MultiAssetDelegation staking contract address (non-devnet).
84    #[arg(long = "staking-contract")]
85    pub staking_contract: Option<String>,
86    /// Override the OperatorStatusRegistry contract address (non-devnet).
87    #[arg(long)]
88    pub status_registry_contract: Option<String>,
89}
90
91impl TangleDeployArgs {
92    fn definition_overrides(&self) -> Result<Option<DefinitionOverrides>> {
93        let override_requested = self.artifact_source.is_some()
94            || self.artifact_entrypoint.is_some()
95            || !self.artifact_binaries.is_empty()
96            || self.github_owner.is_some()
97            || self.github_repo.is_some()
98            || self.github_tag.is_some()
99            || self.remote_dist_url.is_some()
100            || self.remote_archive_url.is_some();
101
102        if !override_requested {
103            return Ok(None);
104        }
105
106        let fetcher = self.artifact_source.ok_or_else(|| {
107            eyre!("--artifact-source must be provided when overriding native metadata")
108        })?;
109
110        let entrypoint = self
111            .artifact_entrypoint
112            .as_ref()
113            .ok_or_else(|| {
114                eyre!("--artifact-entrypoint is required when overriding native metadata")
115            })?
116            .trim()
117            .to_string();
118
119        if entrypoint.is_empty() {
120            return Err(eyre!("--artifact-entrypoint must not be empty"));
121        }
122
123        if self.artifact_binaries.is_empty() {
124            return Err(eyre!(
125                "at least one --artifact-binary must be specified when overriding native metadata"
126            ));
127        }
128
129        let binaries = self
130            .artifact_binaries
131            .iter()
132            .map(|raw| parse_cli_binary(raw))
133            .collect::<Result<Vec<_>>>()?;
134
135        let override_spec = match fetcher {
136            NativeArtifactSource::Github => {
137                let owner = self
138                    .github_owner
139                    .as_ref()
140                    .ok_or_else(|| eyre!("--github-owner is required for GitHub artifacts"))?
141                    .clone();
142                let repo = self
143                    .github_repo
144                    .as_ref()
145                    .ok_or_else(|| eyre!("--github-repo is required for GitHub artifacts"))?
146                    .clone();
147                let tag = self
148                    .github_tag
149                    .as_ref()
150                    .ok_or_else(|| eyre!("--github-tag is required for GitHub artifacts"))?
151                    .clone();
152
153                NativeSourceOverride::github(
154                    entrypoint,
155                    GithubArtifactSpec {
156                        owner,
157                        repo,
158                        tag,
159                        binaries,
160                    },
161                )
162            }
163            NativeArtifactSource::Http | NativeArtifactSource::Ipfs => {
164                let dist_url = self
165                    .remote_dist_url
166                    .as_ref()
167                    .ok_or_else(|| eyre!("--remote-dist-url is required for HTTP/IPFS artifacts"))?
168                    .clone();
169                let archive_url = self
170                    .remote_archive_url
171                    .as_ref()
172                    .ok_or_else(|| {
173                        eyre!("--remote-archive-url is required for HTTP/IPFS artifacts")
174                    })?
175                    .clone();
176
177                NativeSourceOverride::remote(
178                    entrypoint,
179                    fetcher.to_fetcher_kind(),
180                    RemoteArtifactSpec {
181                        dist_url,
182                        archive_url,
183                        binaries,
184                    },
185                )
186            }
187        };
188
189        let mut overrides = DefinitionOverrides::default();
190        overrides.push_native(override_spec);
191        Ok(Some(overrides))
192    }
193}
194
195pub async fn execute(args: TangleDeployArgs) -> Result<()> {
196    let protocol_settings = load_protocol_settings(Protocol::Tangle, &args.settings_file)
197        .map_err(|e| eyre!(e.to_string()))?;
198    let tangle_settings = protocol_settings
199        .tangle()
200        .map_err(|e| eyre!("failed to load Tangle settings: {e}"))?;
201    let tangle_settings = tangle_settings.clone();
202
203    let plan = match args.network {
204        DeploymentNetwork::Devnet => {
205            let stack = DevnetStack::spawn(args.include_anvil_logs).await?;
206            DeploymentPlan::Devnet(DevnetDeployment::new(
207                stack,
208                tangle_settings,
209                args.allow_unchecked_attestations,
210                args.spawn_method,
211                args.exit_after_seconds.map(Duration::from_secs),
212            ))
213        }
214        DeploymentNetwork::Testnet | DeploymentNetwork::Mainnet => {
215            let definition_path = args
216                .definition
217                .as_ref()
218                .ok_or_else(|| eyre!("--definition is required for testnet/mainnet deployments"))?;
219            let overrides = args.definition_overrides()?;
220            let loaded = load_blueprint_definition(definition_path, overrides.as_ref())?;
221            print_source_summaries(&loaded.summaries);
222            let remote = NetworkDeploymentConfig::from_args(&args, &tangle_settings)?;
223            DeploymentPlan::Network(NetworkDeployment::new(
224                args.network,
225                remote,
226                loaded.definition,
227            ))
228        }
229    };
230
231    let outcome = plan.execute().await?;
232    log_deployment_summary(&outcome);
233    Ok(())
234}
235
236#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
237pub enum DeploymentNetwork {
238    Devnet,
239    Testnet,
240    Mainnet,
241}
242
243impl fmt::Display for DeploymentNetwork {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            DeploymentNetwork::Devnet => write!(f, "devnet"),
247            DeploymentNetwork::Testnet => write!(f, "testnet"),
248            DeploymentNetwork::Mainnet => write!(f, "mainnet"),
249        }
250    }
251}
252
253#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
254pub enum NativeArtifactSource {
255    Github,
256    Http,
257    Ipfs,
258}
259
260impl NativeArtifactSource {
261    fn to_fetcher_kind(self) -> FetcherKind {
262        match self {
263            NativeArtifactSource::Github => FetcherKind::Github,
264            NativeArtifactSource::Http => FetcherKind::Http,
265            NativeArtifactSource::Ipfs => FetcherKind::Ipfs,
266        }
267    }
268}
269
270#[derive(Debug)]
271struct DeploymentOutcome {
272    network: DeploymentNetwork,
273    blueprint_id: u64,
274    service_id: Option<u64>,
275    transactions: Vec<TransactionResult>,
276}
277
278impl DeploymentOutcome {
279    fn new(
280        network: DeploymentNetwork,
281        blueprint_id: u64,
282        service_id: Option<u64>,
283        transactions: Vec<TransactionResult>,
284    ) -> Self {
285        Self {
286            network,
287            blueprint_id,
288            service_id,
289            transactions,
290        }
291    }
292}
293
294enum DeploymentPlan {
295    Devnet(DevnetDeployment),
296    Network(NetworkDeployment),
297}
298
299impl DeploymentPlan {
300    async fn execute(self) -> Result<DeploymentOutcome> {
301        match self {
302            DeploymentPlan::Devnet(plan) => plan.execute().await,
303            DeploymentPlan::Network(plan) => plan.execute().await,
304        }
305    }
306}
307
308fn print_source_summaries(summaries: &[SourceSummary]) {
309    if summaries.is_empty() {
310        println!("No blueprint sources were detected in the definition.");
311        return;
312    }
313
314    println!("\nBlueprint sources:");
315    for summary in summaries {
316        let fetcher = summary
317            .fetcher
318            .map(|kind| kind.to_string())
319            .unwrap_or_else(|| "n/a".into());
320        let entrypoint = summary
321            .entrypoint
322            .as_deref()
323            .filter(|value| !value.is_empty())
324            .unwrap_or("n/a");
325        println!(
326            "  [{}] kind: {}, fetcher: {fetcher}, entrypoint: {entrypoint}",
327            summary.index + 1,
328            summary.kind
329        );
330
331        match &summary.details {
332            SourceSummaryDetails::Container {
333                registry,
334                image,
335                tag,
336            } => {
337                println!("      image: {registry}/{image}:{tag}");
338            }
339            SourceSummaryDetails::Native { has_testing } => {
340                if *has_testing {
341                    println!("      includes testing harness");
342                }
343            }
344            SourceSummaryDetails::Wasm { runtime } => {
345                println!("      runtime: {:?}", runtime);
346            }
347        }
348    }
349    println!();
350}
351
352fn parse_cli_binary(value: &str) -> Result<BinaryArtifactSpec> {
353    let mut parts = value.split(':').collect::<Vec<_>>();
354    if parts.len() < 4 || parts.len() > 5 {
355        return Err(eyre!(
356            "invalid --artifact-binary `{value}`; expected NAME:ARCH:OS:SHA256[:BLAKE3]"
357        ));
358    }
359    let blake3 = if parts.len() == 5 {
360        Some(parts.pop().unwrap().to_string())
361    } else {
362        None
363    };
364    let sha256 = parts.pop().unwrap().to_string();
365    let os = parts.pop().unwrap().to_string();
366    let arch = parts.pop().unwrap().to_string();
367    let name = parts.pop().unwrap().to_string();
368    Ok(BinaryArtifactSpec {
369        name,
370        arch,
371        os,
372        sha256,
373        blake3,
374    })
375}
376
377#[derive(Debug)]
378struct DevnetDeployment {
379    stack: DevnetStack,
380    settings: TangleProtocolSettings,
381    allow_unchecked_attestations: bool,
382    spawn_method: SpawnMethod,
383    shutdown_after: Option<Duration>,
384}
385
386impl DevnetDeployment {
387    fn new(
388        stack: DevnetStack,
389        settings: TangleProtocolSettings,
390        allow_unchecked_attestations: bool,
391        spawn_method: SpawnMethod,
392        shutdown_after: Option<Duration>,
393    ) -> Self {
394        Self {
395            stack,
396            settings,
397            allow_unchecked_attestations,
398            spawn_method,
399            shutdown_after,
400        }
401    }
402
403    async fn execute(self) -> Result<DeploymentOutcome> {
404        let Self {
405            stack,
406            settings,
407            allow_unchecked_attestations,
408            spawn_method,
409            shutdown_after,
410        } = self;
411
412        println!(
413            "Deploying blueprint to local Anvil devnet at HTTP {} / WS {}",
414            stack.http_rpc_url(),
415            stack.ws_rpc_url()
416        );
417
418        let mut run_opts = run_opts_from_stack(
419            &stack,
420            &settings,
421            allow_unchecked_attestations,
422            spawn_method,
423        );
424        run_opts.shutdown_after = shutdown_after;
425        let service_id = run_opts.service_id;
426        let run_result = run_blueprint(run_opts).await;
427
428        stack.shutdown().await;
429        run_result?;
430
431        Ok(DeploymentOutcome::new(
432            DeploymentNetwork::Devnet,
433            settings.blueprint_id,
434            service_id,
435            Vec::new(),
436        ))
437    }
438}
439
440#[derive(Debug)]
441struct NetworkDeployment {
442    network: DeploymentNetwork,
443    rpc: NetworkDeploymentConfig,
444    definition: BlueprintDefinitionInput,
445}
446
447impl NetworkDeployment {
448    fn new(
449        network: DeploymentNetwork,
450        rpc: NetworkDeploymentConfig,
451        definition: BlueprintDefinitionInput,
452    ) -> Self {
453        Self {
454            network,
455            rpc,
456            definition,
457        }
458    }
459
460    async fn execute(self) -> Result<DeploymentOutcome> {
461        let Self {
462            network,
463            rpc,
464            definition,
465        } = self;
466
467        println!(
468            "Deploying blueprint definition (metadata {}) to {}",
469            definition.metadata_uri, network
470        );
471
472        let client = rpc.connect_client().await?;
473        let (tx, blueprint_id) = client
474            .create_blueprint(definition.encoded_bytes().to_vec())
475            .await?;
476
477        Ok(DeploymentOutcome::new(
478            network,
479            blueprint_id,
480            None,
481            vec![tx],
482        ))
483    }
484}
485
486#[derive(Clone, Debug)]
487struct NetworkDeploymentConfig {
488    http_rpc_url: Url,
489    ws_rpc_url: Url,
490    keystore_path: PathBuf,
491    tangle_contract: Address,
492    staking_contract: Address,
493    status_registry_contract: Address,
494}
495
496impl NetworkDeploymentConfig {
497    fn from_args(args: &TangleDeployArgs, settings: &TangleProtocolSettings) -> Result<Self> {
498        let http_rpc_url = infer_url(
499            args.http_rpc_url.clone(),
500            HTTP_RPC_URL_ENV,
501            "--http-rpc-url",
502        )?;
503        let ws_rpc_url = infer_url(args.ws_rpc_url.clone(), WS_RPC_URL_ENV, "--ws-rpc-url")?;
504        let keystore_path = resolve_keystore_path(args.keystore_path.clone());
505        let tangle_contract = parse_contract_override(
506            args.tangle_contract.as_deref(),
507            settings.tangle_contract,
508            "TANGLE_CONTRACT",
509            "--tangle-contract",
510        )?;
511        let staking_contract = parse_contract_override(
512            args.staking_contract.as_deref(),
513            settings.staking_contract,
514            "STAKING_CONTRACT",
515            "--staking-contract",
516        )?;
517        let status_registry_contract = parse_contract_override(
518            args.status_registry_contract.as_deref(),
519            settings.status_registry_contract,
520            "STATUS_REGISTRY_CONTRACT",
521            "--status-registry-contract",
522        )?;
523
524        Ok(Self {
525            http_rpc_url,
526            ws_rpc_url,
527            keystore_path,
528            tangle_contract,
529            staking_contract,
530            status_registry_contract,
531        })
532    }
533
534    async fn connect_client(&self) -> Result<TangleClient> {
535        if !self.keystore_path.exists() {
536            return Err(eyre!(
537                "Keystore {} not found; pass --keystore-path or set {KEYSTORE_PATH_ENV}",
538                self.keystore_path.display()
539            ));
540        }
541
542        let settings = TangleSettings {
543            blueprint_id: 0,
544            service_id: None,
545            tangle_contract: self.tangle_contract,
546            staking_contract: self.staking_contract,
547            status_registry_contract: self.status_registry_contract,
548        };
549        let config = TangleClientConfig::new(
550            self.http_rpc_url.clone(),
551            self.ws_rpc_url.clone(),
552            self.keystore_path.display().to_string(),
553            settings,
554        );
555        TangleClient::new(config)
556            .await
557            .map_err(|e| eyre!(e.to_string()))
558    }
559}
560
561fn infer_url(value: Option<Url>, env_key: &str, flag: &str) -> Result<Url> {
562    if let Some(url) = value {
563        return Ok(url);
564    }
565
566    match env::var(env_key) {
567        Ok(raw) => Url::parse(&raw).map_err(|err| eyre!("Invalid {env_key} URL: {err}")),
568        Err(_) => Err(eyre!(
569            "Missing RPC endpoint. Set {env_key} in settings.env or pass {flag}."
570        )),
571    }
572}
573
574fn resolve_keystore_path(explicit: Option<PathBuf>) -> PathBuf {
575    if let Some(path) = explicit {
576        return path;
577    }
578
579    if let Ok(raw) = env::var(KEYSTORE_PATH_ENV) {
580        return PathBuf::from(raw);
581    }
582
583    if let Ok(raw) = env::var("KEYSTORE_URI") {
584        return PathBuf::from(raw);
585    }
586
587    PathBuf::from("./keystore")
588}
589
590fn parse_contract_override(
591    value: Option<&str>,
592    fallback: Address,
593    env_key: &str,
594    flag: &str,
595) -> Result<Address> {
596    if let Some(raw) = value {
597        return parse_address(raw, env_key);
598    }
599
600    if fallback != Address::ZERO {
601        return Ok(fallback);
602    }
603
604    Err(eyre!(
605        "Missing {env_key}. Set it in settings.env or pass {flag}."
606    ))
607}
608
609fn log_deployment_summary(outcome: &DeploymentOutcome) {
610    println!(
611        "\nDeployment complete → network={} blueprint={} service={}",
612        outcome.network,
613        outcome.blueprint_id,
614        outcome
615            .service_id
616            .map_or_else(|| "-".to_string(), |id| id.to_string())
617    );
618
619    if outcome.transactions.is_empty() {
620        println!("No transactions were submitted for this deployment.");
621        return;
622    }
623
624    println!("Submitted transactions:");
625    for tx in &outcome.transactions {
626        println!(
627            "  tx={:#x} block={:?} success={}",
628            tx.tx_hash, tx.block_number, tx.success
629        );
630    }
631}
632
633const HTTP_RPC_URL_ENV: &str = "HTTP_RPC_URL";
634const WS_RPC_URL_ENV: &str = "WS_RPC_URL";