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 #[arg(long, value_enum, default_value_t = DeploymentNetwork::Devnet)]
28 pub network: DeploymentNetwork,
29 #[arg(long, value_name = "FILE", default_value = "./settings.env")]
31 pub settings_file: PathBuf,
32 #[arg(long)]
34 pub include_anvil_logs: bool,
35 #[arg(long)]
37 pub allow_unchecked_attestations: bool,
38 #[arg(long, value_enum, default_value_t = SpawnMethod::Vm)]
40 pub spawn_method: SpawnMethod,
41 #[arg(long, value_name = "SECONDS")]
43 pub exit_after_seconds: Option<u64>,
44 #[arg(long, value_name = "FILE")]
46 pub definition: Option<PathBuf>,
47 #[arg(long, value_enum)]
49 pub artifact_source: Option<NativeArtifactSource>,
50 #[arg(long)]
52 pub artifact_entrypoint: Option<String>,
53 #[arg(long)]
55 pub github_owner: Option<String>,
56 #[arg(long)]
58 pub github_repo: Option<String>,
59 #[arg(long)]
61 pub github_tag: Option<String>,
62 #[arg(long)]
64 pub remote_dist_url: Option<String>,
65 #[arg(long)]
67 pub remote_archive_url: Option<String>,
68 #[arg(long = "artifact-binary", value_name = "NAME:ARCH:OS:SHA256[:BLAKE3]")]
70 pub artifact_binaries: Vec<String>,
71 #[arg(long)]
73 pub http_rpc_url: Option<Url>,
74 #[arg(long)]
76 pub ws_rpc_url: Option<Url>,
77 #[arg(long)]
79 pub keystore_path: Option<PathBuf>,
80 #[arg(long)]
82 pub tangle_contract: Option<String>,
83 #[arg(long = "staking-contract")]
85 pub staking_contract: Option<String>,
86 #[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
662struct 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 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 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}