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, definition.metadata_hash, network
470        );
471
472        let client = rpc.connect_client().await?;
473        let (tx, blueprint_id) = client
474            .create_blueprint(definition.call_data_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 defaults = network_defaults(args.network);
499        let http_rpc_url = infer_url(
500            args.http_rpc_url.clone(),
501            HTTP_RPC_URL_ENV,
502            "--http-rpc-url",
503            defaults.http_rpc_url,
504        )?;
505        let ws_rpc_url = infer_url(
506            args.ws_rpc_url.clone(),
507            WS_RPC_URL_ENV,
508            "--ws-rpc-url",
509            defaults.ws_rpc_url,
510        )?;
511        let keystore_path = resolve_keystore_path(args.keystore_path.clone());
512        let tangle_contract = parse_contract_override(
513            args.tangle_contract.as_deref(),
514            settings.tangle_contract,
515            "TANGLE_CONTRACT",
516            "--tangle-contract",
517            defaults.tangle_contract,
518        )?;
519        let staking_contract = parse_contract_override(
520            args.staking_contract.as_deref(),
521            settings.staking_contract,
522            "STAKING_CONTRACT",
523            "--staking-contract",
524            defaults.staking_contract,
525        )?;
526        let status_registry_contract = parse_contract_override(
527            args.status_registry_contract.as_deref(),
528            settings.status_registry_contract,
529            "STATUS_REGISTRY_CONTRACT",
530            "--status-registry-contract",
531            defaults.status_registry_contract,
532        )?;
533
534        Ok(Self {
535            http_rpc_url,
536            ws_rpc_url,
537            keystore_path,
538            tangle_contract,
539            staking_contract,
540            status_registry_contract,
541        })
542    }
543
544    async fn connect_client(&self) -> Result<TangleClient> {
545        if !self.keystore_path.exists() {
546            return Err(eyre!(
547                "Keystore {} not found; pass --keystore-path or set {KEYSTORE_PATH_ENV}",
548                self.keystore_path.display()
549            ));
550        }
551
552        let settings = TangleSettings {
553            blueprint_id: 0,
554            service_id: None,
555            tangle_contract: self.tangle_contract,
556            staking_contract: self.staking_contract,
557            status_registry_contract: self.status_registry_contract,
558        };
559        let config = TangleClientConfig::new(
560            self.http_rpc_url.clone(),
561            self.ws_rpc_url.clone(),
562            self.keystore_path.display().to_string(),
563            settings,
564        );
565        TangleClient::new(config)
566            .await
567            .map_err(|e| eyre!(e.to_string()))
568    }
569}
570
571fn infer_url(
572    value: Option<Url>,
573    env_key: &str,
574    flag: &str,
575    default: Option<&'static str>,
576) -> Result<Url> {
577    if let Some(url) = value {
578        return Ok(url);
579    }
580
581    if let Ok(raw) = env::var(env_key) {
582        return Url::parse(&raw).map_err(|err| eyre!("Invalid {env_key} URL: {err}"));
583    }
584
585    if let Some(raw) = default {
586        return Url::parse(raw)
587            .map_err(|err| eyre!("Built-in default URL for {env_key} failed to parse: {err}"));
588    }
589
590    Err(eyre!(
591        "Missing RPC endpoint. Set {env_key} in settings.env or pass {flag}."
592    ))
593}
594
595fn resolve_keystore_path(explicit: Option<PathBuf>) -> PathBuf {
596    if let Some(path) = explicit {
597        return path;
598    }
599
600    if let Ok(raw) = env::var(KEYSTORE_PATH_ENV) {
601        return PathBuf::from(raw);
602    }
603
604    if let Ok(raw) = env::var("KEYSTORE_URI") {
605        return PathBuf::from(raw);
606    }
607
608    PathBuf::from("./keystore")
609}
610
611fn parse_contract_override(
612    value: Option<&str>,
613    fallback: Address,
614    env_key: &str,
615    flag: &str,
616    default: Option<&'static str>,
617) -> Result<Address> {
618    if let Some(raw) = value {
619        return parse_address(raw, env_key);
620    }
621
622    if fallback != Address::ZERO {
623        return Ok(fallback);
624    }
625
626    if let Some(raw) = default {
627        return parse_address(raw, env_key);
628    }
629
630    Err(eyre!(
631        "Missing {env_key}. Set it in settings.env or pass {flag}."
632    ))
633}
634
635fn log_deployment_summary(outcome: &DeploymentOutcome) {
636    println!(
637        "\nDeployment complete → network={} blueprint={} service={}",
638        outcome.network,
639        outcome.blueprint_id,
640        outcome
641            .service_id
642            .map_or_else(|| "-".to_string(), |id| id.to_string())
643    );
644
645    if outcome.transactions.is_empty() {
646        println!("No transactions were submitted for this deployment.");
647        return;
648    }
649
650    println!("Submitted transactions:");
651    for tx in &outcome.transactions {
652        println!(
653            "  tx={:#x} block={:?} success={}",
654            tx.tx_hash, tx.block_number, tx.success
655        );
656    }
657}
658
659const HTTP_RPC_URL_ENV: &str = "HTTP_RPC_URL";
660const WS_RPC_URL_ENV: &str = "WS_RPC_URL";
661
662/// Built-in endpoints + protocol addresses per network.
663///
664/// Order of precedence inside [`NetworkDeploymentConfig::from_args`] is
665/// explicit CLI flag → `settings.env` value → these built-in defaults
666/// → error. The defaults exist so the common case (`cargo tangle
667/// blueprint deploy tangle --network testnet --definition …`) Just
668/// Works without every blueprint repo carrying its own copy of the
669/// same RPC URL + contract address.
670///
671/// Mainnet entries stay `None` until a real production deployment
672/// exists; until then the script will continue to require explicit
673/// configuration on `--network mainnet`.
674struct NetworkDefaults {
675    http_rpc_url: Option<&'static str>,
676    ws_rpc_url: Option<&'static str>,
677    tangle_contract: Option<&'static str>,
678    staking_contract: Option<&'static str>,
679    status_registry_contract: Option<&'static str>,
680}
681
682const NETWORK_DEFAULTS_TESTNET: NetworkDefaults = NetworkDefaults {
683    // Base Sepolia (chainId 84532). Addresses match the first published
684    // testnet deployment of tnt-core.
685    http_rpc_url: Some("https://sepolia.base.org"),
686    ws_rpc_url: Some("wss://base-sepolia-rpc.publicnode.com"),
687    tangle_contract: Some("0xC9b0716a187072be0f38A5D972392C6479b9Cfe3"),
688    staking_contract: Some("0xfEB417fc6d343e0fc88EC9fDb8294BF84d69F0Ca"),
689    status_registry_contract: Some("0x81443688Fce1e4eDb822c1D5794C3DAc608e9a23"),
690};
691
692const NETWORK_DEFAULTS_MAINNET: NetworkDefaults = NetworkDefaults {
693    http_rpc_url: None,
694    ws_rpc_url: None,
695    tangle_contract: None,
696    staking_contract: None,
697    status_registry_contract: None,
698};
699
700const NETWORK_DEFAULTS_DEVNET: NetworkDefaults = NetworkDefaults {
701    // Devnet uses an in-process anvil spawn (see `DeploymentNetwork::Devnet`
702    // arm in `run`), so these built-ins are unused — present for symmetry.
703    http_rpc_url: None,
704    ws_rpc_url: None,
705    tangle_contract: None,
706    staking_contract: None,
707    status_registry_contract: None,
708};
709
710fn network_defaults(network: DeploymentNetwork) -> &'static NetworkDefaults {
711    match network {
712        DeploymentNetwork::Devnet => &NETWORK_DEFAULTS_DEVNET,
713        DeploymentNetwork::Testnet => &NETWORK_DEFAULTS_TESTNET,
714        DeploymentNetwork::Mainnet => &NETWORK_DEFAULTS_MAINNET,
715    }
716}