Skip to main content

blueprint_chain_setup_tangle/
deploy.rs

1use alloy_network::{AnyNetwork, ReceiptResponse};
2use alloy_provider::network::TransactionBuilder;
3use alloy_provider::{Provider, WsConnect};
4use alloy_rpc_types::serde_helpers::WithOtherFields;
5use alloy_rpc_types_eth::TransactionRequest;
6use alloy_signer_local::PrivateKeySigner;
7use blueprint_chain_setup_common::signer::{load_evm_signer_from_env, load_signer_from_env};
8use blueprint_crypto::tangle_pair_signer::TanglePairSigner;
9use blueprint_std::fmt::Debug;
10use blueprint_std::io::{BufRead, BufReader};
11use blueprint_std::path::Path;
12use blueprint_std::path::PathBuf;
13use blueprint_std::process::{Command, Stdio};
14use blueprint_std::{env, rand, thread};
15use blueprint_tangle_extra::metadata::types::blueprint::BlueprintServiceManager;
16use color_eyre::eyre::{self, Context, ContextCompat, Result, eyre};
17use dialoguer::console::style;
18use indicatif::{ProgressBar, ProgressStyle};
19use std::sync::{
20    Arc,
21    atomic::{AtomicBool, Ordering},
22};
23use subxt::tx::Signer;
24use tangle_subxt::subxt;
25use tangle_subxt::tangle_testnet_runtime::api as TangleApi;
26use tangle_subxt::tangle_testnet_runtime::api::services::calls::types::create_blueprint::Blueprint;
27
28#[derive(Clone)]
29pub struct Opts {
30    /// The name of the package to deploy (if the workspace has multiple packages)
31    pub pkg_name: Option<String>,
32    /// The HTTP RPC URL of the Tangle Network
33    pub http_rpc_url: String,
34    /// The WS RPC URL of the Tangle Network
35    pub ws_rpc_url: String,
36    /// The path to the manifest file
37    pub manifest_path: blueprint_std::path::PathBuf,
38    /// The signer for deploying the blueprint
39    pub signer: Option<TanglePairSigner<sp_core::sr25519::Pair>>,
40    /// The signer for deploying the smart contract
41    pub signer_evm: Option<PrivateKeySigner>,
42}
43
44impl Debug for Opts {
45    fn fmt(&self, f: &mut blueprint_std::fmt::Formatter<'_>) -> blueprint_std::fmt::Result {
46        f.debug_struct("Opts")
47            .field("pkg_name", &self.pkg_name)
48            .field("http_rpc_url", &self.http_rpc_url)
49            .field("ws_rpc_url", &self.ws_rpc_url)
50            .field("manifest_path", &self.manifest_path)
51            .finish_non_exhaustive()
52    }
53}
54
55#[derive(thiserror::Error, Debug)]
56pub enum Error {
57    #[error("Unsupported blueprint manager kind")]
58    UnsupportedBlueprintManager,
59    #[error("Contract not found at `{0}`, check the manager in your `Cargo.toml`!")]
60    ContractNotFound(PathBuf),
61    #[error("Failed to deserialize contract `{0}`: {1}")]
62    DeserializeContract(String, serde_json::Error),
63    #[error("The source at index {0} does not have a valid fetcher")]
64    MissingFetcher(usize),
65    #[error("No matching packages found in the workspace")]
66    NoPackageFound,
67    #[error("The workspace has multiple packages, please specify the package to deploy")]
68    ManyPackages,
69
70    #[error("{0}")]
71    Io(#[from] std::io::Error),
72}
73
74async fn generate_service_blueprint<P: Into<PathBuf>, T: AsRef<str>>(
75    manifest_metadata_path: P,
76    pkg_name: Option<&String>,
77    rpc_url: T,
78    signer_evm: Option<PrivateKeySigner>,
79) -> Result<Blueprint> {
80    let manifest_path = manifest_metadata_path.into();
81    let metadata = cargo_metadata::MetadataCommand::new()
82        .manifest_path(manifest_path)
83        .no_deps()
84        .exec()
85        .context("Getting Metadata about the workspace")?;
86
87    let package = find_package(&metadata, pkg_name)?.clone();
88
89    let mut blueprint = load_blueprint_metadata(&package)?;
90    build_contracts_if_needed(&package, &blueprint).context("Building contracts")?;
91    deploy_contracts_to_tangle(rpc_url.as_ref(), &package, &mut blueprint, signer_evm).await?;
92
93    Ok(blueprint.try_into()?)
94}
95
96/// Deploy a blueprint to the Tangle Network
97///
98/// # Errors
99///
100/// * Any blueprint metadata is malformed
101/// * If `signer` is not provided, see [`load_signer_from_env()`]
102/// * `ws_rpc_url` is invalid
103/// * No `BlueprintCreated` event found under `signer`
104pub async fn deploy_to_tangle(
105    Opts {
106        pkg_name,
107        http_rpc_url: _,
108        ws_rpc_url,
109        manifest_path,
110        signer,
111        signer_evm,
112    }: Opts,
113) -> Result<u64> {
114    // Create a progress bar to track deployment
115    let progress_bar = ProgressBar::new(100);
116    progress_bar.set_style(
117        ProgressStyle::default_bar()
118            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}% {msg}")?
119            .progress_chars("#>-"),
120    );
121
122    // Helper function to update progress
123    let update_progress = |percent: u64, message: &str| {
124        progress_bar.set_position(percent);
125        progress_bar.set_message(message.to_string());
126    };
127
128    // Start the deployment process
129    update_progress(0, "Generating blueprint");
130
131    // Create a flag to signal the thread to stop
132    let should_stop = Arc::new(AtomicBool::new(false));
133    let should_stop_clone = should_stop.clone();
134
135    // Create a background thread to update the progress bar during the long-running operation
136    let progress_clone = progress_bar.clone();
137    let _progress_handle = std::thread::spawn(move || {
138        let mut current = 0;
139        let target = 80;
140        let increment = rand::random::<u64>() % 2 + 1;
141        let sleep_duration = std::time::Duration::from_millis(1000);
142
143        while current < target && !should_stop_clone.load(Ordering::Relaxed) {
144            std::thread::sleep(sleep_duration);
145            current += increment;
146            progress_clone.set_position(current);
147            progress_clone.set_message(format!("Generating blueprint... ({current}%)"));
148        }
149    });
150
151    // Load the manifest file into cargo metadata
152    update_progress(60, "Generating blueprint");
153    let blueprint = generate_service_blueprint(
154        manifest_path,
155        pkg_name.as_ref(),
156        ws_rpc_url.clone(),
157        signer_evm,
158    )
159    .await?;
160
161    // Signal the thread to stop
162    should_stop.store(true, Ordering::Relaxed);
163
164    // Blueprint generation is complete, update to 80% if not already there
165    if progress_bar.position() < 80 {
166        update_progress(80, "Blueprint generated");
167    } else {
168        update_progress(progress_bar.position(), "Blueprint generated");
169    }
170
171    // Give the thread a moment to notice the stop signal
172    std::thread::sleep(std::time::Duration::from_millis(100));
173
174    let signer = if let Some(signer) = signer {
175        signer
176    } else {
177        load_signer_from_env()?
178    };
179
180    let my_account_id = signer.account_id();
181    update_progress(85, &format!("Connected to Tangle Network at: {ws_rpc_url}"));
182    let client = subxt::OnlineClient::from_url(ws_rpc_url.clone()).await?;
183
184    update_progress(90, "Creating blueprint transaction");
185    let create_blueprint_tx = TangleApi::tx().services().create_blueprint(blueprint);
186
187    update_progress(93, "Signing and submitting transaction");
188    let progress = client
189        .tx()
190        .sign_and_submit_then_watch_default(&create_blueprint_tx, &signer)
191        .await?;
192
193    update_progress(95, "Waiting for transaction confirmation");
194    let result = if cfg!(test) {
195        use blueprint_tangle_extra::util::TxProgressExt;
196        progress.wait_for_in_block_success().await?
197    } else {
198        blueprint_core::debug!("Waiting for the transaction to be finalized...");
199        let result = progress.wait_for_finalized_success().await?;
200        blueprint_core::debug!("Transaction finalized...");
201        result
202    };
203
204    update_progress(98, "Verifying blueprint creation");
205    let event = result
206        .find::<TangleApi::services::events::BlueprintCreated>()
207        .flatten()
208        .find(|e| e.owner.0 == my_account_id.0)
209        .context("Finding the `BlueprintCreated` event")
210        .map_err(|e| {
211            eyre::eyre!(
212                "Trying to find the `BlueprintCreated` event with your account Id: {:?}",
213                e
214            )
215        })?;
216
217    update_progress(100, "Deployment complete");
218    progress_bar.finish_with_message("Blueprint deployed successfully!");
219
220    blueprint_core::info!(
221        "Blueprint #{} created successfully by {} with extrinsic hash: {}",
222        event.blueprint_id,
223        event.owner,
224        result.extrinsic_hash(),
225    );
226
227    Ok(event.blueprint_id)
228}
229
230/// Gets either `CARGO_WORKSPACE_DIR` for Tangle blueprints, or the workspace root directory
231/// identified by finding a Cargo.toml with a [workspace] section. If no workspace is found,
232/// falls back to the package manifest directory.
233fn workspace_or_package_manifest_path(package: &cargo_metadata::Package) -> PathBuf {
234    env::var("CARGO_WORKSPACE_DIR").map_or_else(|_| find_workspace_root(package), PathBuf::from)
235}
236
237/// Finds the workspace root directory by walking up the directory tree and looking for a
238/// Cargo.toml with a [workspace] section. If no workspace root is found, returns the package directory.
239fn find_workspace_root(package: &cargo_metadata::Package) -> PathBuf {
240    let package_dir = package
241        .manifest_path
242        .parent()
243        .unwrap()
244        .as_std_path()
245        .to_path_buf();
246    let mut current_dir = package_dir.clone();
247    let mut workspace_root = package_dir;
248
249    // Walk up the directory tree looking for a workspace root
250    while let Some(parent) = current_dir.parent() {
251        let potential_cargo_toml = parent.join("Cargo.toml");
252        blueprint_core::debug!(
253            "Looking for Cargo.toml at: {}",
254            potential_cargo_toml.display()
255        );
256        if potential_cargo_toml.exists() {
257            blueprint_core::debug!("Found Cargo.toml");
258            // Check if this Cargo.toml has a [workspace] section
259            if let Ok(content) = std::fs::read_to_string(&potential_cargo_toml) {
260                if content.contains("[workspace]") {
261                    blueprint_core::debug!(
262                        "Found [workspace] section, using this directory as workspace root"
263                    );
264                    workspace_root = parent.to_path_buf();
265                    break;
266                }
267            }
268        }
269        current_dir = parent.to_path_buf();
270
271        // Stop at the filesystem root
272        if current_dir.parent().is_none() {
273            break;
274        }
275    }
276
277    blueprint_core::debug!("Identified workspace root: {:?}", workspace_root);
278    workspace_root
279}
280
281fn do_cargo_build(manifest_path: &Path) -> Result<()> {
282    let mut cmd = Command::new("cargo");
283    cmd.arg("build")
284        .arg("--manifest-path")
285        .arg(manifest_path)
286        .arg("--all")
287        .stdout(Stdio::piped())
288        .stderr(Stdio::piped());
289
290    let mut child = cmd.spawn().expect("Failed to start cargo build");
291    let stdout = child.stdout.take().expect("Failed to capture stdout");
292    let stderr = child.stderr.take().expect("Failed to capture stderr");
293
294    let stdout_thread = thread::spawn(move || {
295        let reader = BufReader::new(stdout);
296        for line in reader.lines().map_while(Result::ok) {
297            blueprint_core::debug!(target: "build-output", "{}", line);
298        }
299    });
300
301    let stderr_thread = thread::spawn(move || {
302        let reader = BufReader::new(stderr);
303        for line in reader.lines().map_while(Result::ok) {
304            blueprint_core::debug!(target: "build-output", "{}", line);
305        }
306    });
307
308    let status = child.wait().expect("Failed to wait on cargo build");
309
310    stdout_thread.join().expect("Stdout thread panicked");
311    stderr_thread.join().expect("Stderr thread panicked");
312
313    if !status.success() {
314        blueprint_core::error!("Cargo build failed");
315        blueprint_core::error!("NOTE: Use `RUST_LOG=build-output=debug` to see more details");
316        return Err(eyre!("Cargo build failed"));
317    }
318
319    Ok(())
320}
321
322fn load_blueprint_metadata(
323    package: &cargo_metadata::Package,
324) -> Result<blueprint_tangle_extra::metadata::types::blueprint::ServiceBlueprint<'static>> {
325    // Find the workspace root
326    let workspace_root = find_workspace_root(package);
327    let package_dir = package
328        .manifest_path
329        .parent()
330        .unwrap()
331        .as_std_path()
332        .to_path_buf();
333
334    // First check in the workspace root directory
335    let mut blueprint_json_path = workspace_root.join("blueprint.json");
336
337    // If not found in workspace root, check in the binary's directory
338    if !blueprint_json_path.exists() {
339        blueprint_json_path = package_dir.join("blueprint.json");
340    }
341
342    if !blueprint_json_path.exists() {
343        blueprint_core::warn!(
344            "Could not find blueprint.json in workspace root or binary directory; running `cargo build`..."
345        );
346        blueprint_core::debug!(
347            "Looked for blueprint.json at workspace root: {:?}",
348            workspace_root.join("blueprint.json")
349        );
350        blueprint_core::debug!(
351            "Looked for blueprint.json at package dir: {:?}",
352            package_dir.join("blueprint.json")
353        );
354
355        // Need to run cargo build. We don't know the package name, so unfortunately this will
356        // build the entire workspace.
357        do_cargo_build(&workspace_root.join("Cargo.toml"))?;
358    }
359
360    // Check for the blueprint.json file again
361    blueprint_json_path = workspace_root.join("blueprint.json");
362
363    // If not found in workspace root, check in the binary's directory
364    if !blueprint_json_path.exists() {
365        blueprint_json_path = package_dir.join("blueprint.json");
366    }
367
368    blueprint_core::debug!("Found blueprint.json at: {:?}", blueprint_json_path);
369
370    // should have the blueprint.json
371    let blueprint_json = std::fs::read_to_string(&blueprint_json_path).context(format!(
372        "Reading blueprint.json file at {blueprint_json_path:?}",
373    ))?;
374    let blueprint = serde_json::from_str(&blueprint_json)?;
375    Ok(blueprint)
376}
377
378async fn deploy_contracts_to_tangle(
379    rpc_url: &str,
380    package: &cargo_metadata::Package,
381    blueprint: &mut blueprint_tangle_extra::metadata::types::blueprint::ServiceBlueprint<'static>,
382    signer_evm: Option<PrivateKeySigner>,
383) -> Result<()> {
384    enum ContractKind {
385        Manager,
386    }
387    let contract_paths = match blueprint.manager {
388        BlueprintServiceManager::Evm(ref path) => vec![(ContractKind::Manager, path)],
389        _ => return Err(Error::UnsupportedBlueprintManager.into()),
390    };
391
392    let abs_contract_paths: Vec<_> = contract_paths
393        .into_iter()
394        .map(|(kind, path)| (kind, resolve_path_relative_to_package(package, path)))
395        .collect();
396
397    let mut contracts_raw = Vec::new();
398    for (kind, path) in abs_contract_paths {
399        if !path.exists() {
400            return Err(Error::ContractNotFound(path).into());
401        }
402
403        let content = std::fs::read_to_string(&path)?;
404        contracts_raw.push((
405            kind,
406            path.file_stem()
407                .unwrap_or_default()
408                .to_string_lossy()
409                .into_owned(),
410            content,
411        ));
412    }
413
414    let mut contracts = Vec::new();
415    for (kind, contract_name, json) in contracts_raw {
416        let contract = match serde_json::from_str::<alloy_json_abi::ContractObject>(&json) {
417            Ok(contract) => contract,
418            Err(e) => return Err(Error::DeserializeContract(contract_name, e).into()),
419        };
420
421        contracts.push((kind, contract_name, contract));
422    }
423
424    // ...
425    if contracts.is_empty() {
426        return Ok(());
427    }
428
429    let signer = if let Some(signer) = signer_evm {
430        signer
431    } else {
432        load_evm_signer_from_env()?
433    };
434
435    let wallet = alloy_provider::network::EthereumWallet::from(signer);
436    assert!(rpc_url.starts_with("ws://") || rpc_url.starts_with("wss://"));
437
438    let provider = alloy_provider::ProviderBuilder::new()
439        .network::<AnyNetwork>()
440        .wallet(wallet)
441        .connect_ws(WsConnect::new(rpc_url))
442        .await?;
443
444    let chain_id = provider.get_chain_id().await?;
445    blueprint_core::debug!("Chain ID: {chain_id}");
446
447    for (kind, name, contract) in contracts {
448        blueprint_core::info!("Deploying contract: {name} ...");
449        let Some(bytecode) = contract.bytecode.clone() else {
450            blueprint_core::warn!("Contract {name} does not have deployed bytecode! Skipping ...");
451            continue;
452        };
453
454        let tx = TransactionRequest::default().with_deploy_code(bytecode);
455        // Deploy the contract.
456        let receipt = provider
457            .send_transaction(WithOtherFields::new(tx))
458            .await?
459            .get_receipt()
460            .await?;
461        // Check the receipt status.
462        if receipt.status() {
463            let contract_address =
464                alloy_network::ReceiptResponse::contract_address(&receipt).unwrap();
465            blueprint_core::info!("Contract {name} deployed at: {contract_address}");
466            println!(
467                "   {}",
468                style(format!("Contract {name} deployed at: {contract_address}")).yellow()
469            );
470
471            match kind {
472                ContractKind::Manager => {
473                    blueprint.manager = BlueprintServiceManager::Evm(contract_address.to_string());
474                }
475            }
476        } else {
477            blueprint_core::error!("Contract {name} deployment failed!");
478            blueprint_core::debug!("Receipt: {receipt:#?}");
479        }
480    }
481    Ok(())
482}
483
484/// Checks if the contracts need to be built and builds them if needed.
485fn build_contracts_if_needed(
486    package: &cargo_metadata::Package,
487    blueprint: &blueprint_tangle_extra::metadata::types::blueprint::ServiceBlueprint<'static>,
488) -> Result<()> {
489    let pathes_to_check = match blueprint.manager {
490        BlueprintServiceManager::Evm(ref path) => vec![path],
491        _ => return Err(Error::UnsupportedBlueprintManager.into()),
492    };
493
494    blueprint_core::debug!("Checking for contracts to build: {pathes_to_check:?}");
495
496    let abs_pathes_to_check: Vec<_> = pathes_to_check
497        .into_iter()
498        .map(|path| resolve_path_relative_to_package(package, path))
499        .collect();
500
501    blueprint_core::debug!("Absolute paths to check: {abs_pathes_to_check:?}");
502
503    let needs_build = abs_pathes_to_check.iter().any(|path| !path.exists());
504    if !needs_build {
505        blueprint_core::debug!("All contracts are already built");
506        return Ok(());
507    }
508
509    blueprint_core::debug!("Contracts need to be built");
510
511    // Find the workspace root
512    let workspace_root = find_workspace_root(package);
513    let package_dir = package
514        .manifest_path
515        .parent()
516        .unwrap()
517        .as_std_path()
518        .to_path_buf();
519
520    blueprint_core::debug!("Workspace root directory: {workspace_root:?}");
521
522    // Look for contracts directory in the workspace root
523    let mut contracts_dir = workspace_root.join("contracts");
524    if !contracts_dir.exists() {
525        blueprint_core::debug!(
526            "Contracts directory not found in workspace root: {contracts_dir:?}"
527        );
528
529        // Fall back to package directory if not found in workspace root
530        let package_contracts_dir = package_dir.join("contracts");
531        if !package_contracts_dir.exists() {
532            blueprint_core::debug!(
533                "Contracts directory not found in package directory: {package_contracts_dir:?}"
534            );
535            return Err(Error::ContractNotFound(contracts_dir).into());
536        }
537
538        blueprint_core::debug!("Using contracts directory from package: {package_contracts_dir:?}");
539        contracts_dir = package_contracts_dir;
540    }
541
542    blueprint_core::debug!("Contracts directory: {contracts_dir:?}");
543
544    let foundry = crate::foundry::FoundryToolchain::new();
545    foundry.check_installed_or_exit();
546
547    // Change to workspace root directory before building
548    blueprint_core::debug!("Changing to workspace root directory: {workspace_root:?}");
549    std::env::set_current_dir(workspace_root)?;
550    foundry.forge.install_dependencies()?;
551    foundry.forge.build()?;
552
553    // Verify the build succeeded
554    for path in &abs_pathes_to_check {
555        if !path.exists() {
556            return Err(Error::ContractNotFound(path.clone()).into());
557        }
558    }
559
560    Ok(())
561}
562
563/// Resolves a path relative to the package manifest.
564fn resolve_path_relative_to_package(
565    package: &cargo_metadata::Package,
566    path: &str,
567) -> std::path::PathBuf {
568    if path.starts_with('/') {
569        std::path::PathBuf::from(path)
570    } else {
571        workspace_or_package_manifest_path(package).join(path)
572    }
573}
574
575/// Finds a package in the workspace to deploy.
576fn find_package<'m>(
577    metadata: &'m cargo_metadata::Metadata,
578    pkg_name: Option<&String>,
579) -> Result<&'m cargo_metadata::Package, eyre::Error> {
580    match metadata.workspace_members.len() {
581        0 => Err(Error::NoPackageFound.into()),
582        1 => metadata
583            .packages
584            .iter()
585            .find(|p| p.id == metadata.workspace_members[0])
586            .ok_or(Error::NoPackageFound.into()),
587        _more_than_one if pkg_name.is_some() => metadata
588            .packages
589            .iter()
590            .find(|p| pkg_name.is_some_and(|v| &p.name == v))
591            .ok_or(Error::NoPackageFound.into()),
592        _otherwise => {
593            // Find all binary packages in the workspace
594            let bin_packages: Vec<&cargo_metadata::Package> = metadata
595                .packages
596                .iter()
597                .filter(|p| {
598                    p.targets
599                        .iter()
600                        .any(|t| t.kind.contains(&"bin".to_string()))
601                })
602                .collect();
603
604            match bin_packages.len() {
605                0 => {
606                    eprintln!("No binary packages found in the workspace.");
607                    Err(Error::NoPackageFound.into())
608                }
609                1 => {
610                    // If there's only one binary package, use it automatically
611                    blueprint_core::info!(
612                        "Automatically selecting the only binary package: {}",
613                        bin_packages[0].name
614                    );
615                    Ok(bin_packages[0])
616                }
617                _ => {
618                    // If there are multiple binary packages, prompt for selection
619                    eprintln!(
620                        "Multiple binary packages found. Please specify the package to deploy:"
621                    );
622                    for package in &bin_packages {
623                        eprintln!("Found: {}", package.name);
624                    }
625                    eprintln!();
626                    Err(Error::ManyPackages.into())
627                }
628            }
629        }
630    }
631}