cargo_tangle/
deploy.rs

1use alloy_provider::network::TransactionBuilder;
2use alloy_provider::{Provider, WsConnect};
3pub use alloy_signer_local::PrivateKeySigner;
4use color_eyre::eyre::{self, Context, ContextCompat, Result};
5use gadget_blueprint_proc_macro_core::{BlueprintManager, ServiceBlueprint};
6use gadget_sdk::clients::tangle::runtime::TangleConfig;
7pub use k256;
8use std::fmt::Debug;
9use std::path::PathBuf;
10use tangle_subxt::subxt;
11use tangle_subxt::subxt::ext::sp_core;
12use tangle_subxt::subxt::tx::PairSigner;
13use tangle_subxt::tangle_testnet_runtime::api as TangleApi;
14use tangle_subxt::tangle_testnet_runtime::api::services::calls::types;
15
16pub type TanglePairSigner = PairSigner<TangleConfig, sp_core::sr25519::Pair>;
17
18#[derive(Clone)]
19pub struct Opts {
20    /// The name of the package to deploy (if the workspace has multiple packages)
21    pub pkg_name: Option<String>,
22    /// The HTTP RPC URL of the Tangle Network
23    pub http_rpc_url: String,
24    /// The WS RPC URL of the Tangle Network
25    pub ws_rpc_url: String,
26    /// The path to the manifest file
27    pub manifest_path: std::path::PathBuf,
28    /// The signer for deploying the blueprint
29    pub signer: Option<TanglePairSigner>,
30    /// The signer for deploying the smart contract
31    pub signer_evm: Option<PrivateKeySigner>,
32}
33
34impl Debug for Opts {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("Opts")
37            .field("pkg_name", &self.pkg_name)
38            .field("http_rpc_url", &self.http_rpc_url)
39            .field("ws_rpc_url", &self.ws_rpc_url)
40            .field("manifest_path", &self.manifest_path)
41            .finish()
42    }
43}
44
45#[derive(thiserror::Error, Debug)]
46pub enum Error {
47    #[error("Unsupported blueprint manager kind")]
48    UnsupportedBlueprintManager,
49    #[error("Contract not found at `{0}`, check the manager in your `Cargo.toml`!")]
50    ContractNotFound(PathBuf),
51    #[error("Failed to deserialize contract `{0}`: {1}")]
52    DeserializeContract(String, serde_json::Error),
53    #[error("The source at index {0} does not have a valid fetcher")]
54    MissingFetcher(usize),
55    #[error("No matching packages found in the workspace")]
56    NoPackageFound,
57    #[error("The workspace has multiple packages, please specify the package to deploy")]
58    ManyPackages,
59
60    #[error("{0}")]
61    Io(#[from] std::io::Error),
62}
63
64pub async fn generate_service_blueprint<P: Into<PathBuf>, T: AsRef<str>>(
65    manifest_metadata_path: P,
66    pkg_name: Option<&String>,
67    rpc_url: T,
68    signer_evm: Option<PrivateKeySigner>,
69) -> Result<types::create_blueprint::Blueprint> {
70    let manifest_path = manifest_metadata_path.into();
71    let metadata = cargo_metadata::MetadataCommand::new()
72        .manifest_path(manifest_path)
73        .no_deps()
74        .exec()
75        .context("Getting Metadata about the workspace")?;
76
77    let package = find_package(&metadata, pkg_name)?.clone();
78    let package_clone = &package.clone();
79    let mut blueprint = load_blueprint_metadata(&package)?;
80    build_contracts_if_needed(package_clone, &blueprint).context("Building contracts")?;
81    deploy_contracts_to_tangle(rpc_url.as_ref(), package_clone, &mut blueprint, signer_evm).await?;
82
83    bake_blueprint(blueprint)
84}
85
86pub async fn deploy_to_tangle(
87    Opts {
88        pkg_name,
89        http_rpc_url: _,
90        ws_rpc_url,
91        manifest_path,
92        signer,
93        signer_evm,
94    }: Opts,
95) -> Result<u64> {
96    // Load the manifest file into cargo metadata
97    let blueprint =
98        generate_service_blueprint(&manifest_path, pkg_name.as_ref(), &ws_rpc_url, signer_evm)
99            .await?;
100
101    let signer = if let Some(signer) = signer {
102        signer
103    } else {
104        crate::signer::load_signer_from_env()?
105    };
106
107    let my_account_id = signer.account_id();
108    let client = subxt::OnlineClient::from_url(ws_rpc_url.clone()).await?;
109    println!("Connected to Tangle Network at: {}", ws_rpc_url);
110    let create_blueprint_tx = TangleApi::tx().services().create_blueprint(blueprint);
111    println!("Created blueprint...");
112    let progress = client
113        .tx()
114        .sign_and_submit_then_watch_default(&create_blueprint_tx, &signer)
115        .await?;
116    let result = if cfg!(test) {
117        use gadget_sdk::tx::tangle::TxProgressExt;
118        progress.wait_for_in_block_success().await?
119    } else {
120        println!("Waiting for the transaction to be finalized...");
121        let result = progress.wait_for_finalized_success().await?;
122        println!("Transaction finalized...");
123        result
124    };
125    let event = result
126        .find::<TangleApi::services::events::BlueprintCreated>()
127        .flatten()
128        .find(|e| e.owner.0 == my_account_id.0)
129        .context("Finding the `BlueprintCreated` event")
130        .map_err(|e| {
131            eyre::eyre!(
132                "Trying to find the `BlueprintCreated` event with your account Id: {:?}",
133                e
134            )
135        })?;
136    println!(
137        "Blueprint #{} created successfully by {} with extrinsic hash: {}",
138        event.blueprint_id,
139        event.owner,
140        result.extrinsic_hash(),
141    );
142
143    Ok(event.blueprint_id)
144}
145
146pub fn load_blueprint_metadata(
147    package: &cargo_metadata::Package,
148) -> Result<ServiceBlueprint<'static>> {
149    let blueprint_json_path = package
150        .manifest_path
151        .parent()
152        .map(|p| p.join("blueprint.json"))
153        .unwrap();
154
155    if !blueprint_json_path.exists() {
156        eprintln!("Could not find blueprint.json; running `cargo build`...");
157        // Need to run cargo build for the current package.
158        escargot::CargoBuild::new()
159            .manifest_path(&package.manifest_path)
160            .package(&package.name)
161            .run()
162            .context("Failed to build the package")?;
163    }
164    // should have the blueprint.json
165    let blueprint_json =
166        std::fs::read_to_string(blueprint_json_path).context("Reading blueprint.json file")?;
167    let blueprint = serde_json::from_str(&blueprint_json)?;
168    Ok(blueprint)
169}
170
171async fn deploy_contracts_to_tangle(
172    rpc_url: &str,
173    package: &cargo_metadata::Package,
174    blueprint: &mut ServiceBlueprint<'_>,
175    signer_evm: Option<PrivateKeySigner>,
176) -> Result<()> {
177    enum ContractKind {
178        Manager,
179    }
180    let contract_paths = match blueprint.manager {
181        BlueprintManager::Evm(ref path) => vec![(ContractKind::Manager, path)],
182        _ => return Err(Error::UnsupportedBlueprintManager.into()),
183    };
184
185    let abs_contract_paths: Vec<_> = contract_paths
186        .into_iter()
187        .map(|(kind, path)| (kind, resolve_path_relative_to_package(package, path)))
188        .collect();
189
190    let mut contracts_raw = Vec::new();
191    for (kind, path) in abs_contract_paths {
192        if !path.exists() {
193            return Err(Error::ContractNotFound(path).into());
194        }
195
196        let content = std::fs::read_to_string(&path)?;
197        contracts_raw.push((
198            kind,
199            path.file_stem()
200                .unwrap_or_default()
201                .to_string_lossy()
202                .into_owned(),
203            content,
204        ));
205    }
206
207    let mut contracts = Vec::new();
208    for (kind, contract_name, json) in contracts_raw {
209        let contract = match serde_json::from_str::<alloy_json_abi::ContractObject>(&json) {
210            Ok(contract) => contract,
211            Err(e) => return Err(Error::DeserializeContract(contract_name, e).into()),
212        };
213
214        contracts.push((kind, contract_name, contract));
215    }
216
217    if contracts.is_empty() {
218        return Ok(());
219    }
220
221    let signer = if let Some(signer) = signer_evm {
222        signer
223    } else {
224        crate::signer::load_evm_signer_from_env()?
225    };
226
227    let wallet = alloy_provider::network::EthereumWallet::from(signer);
228    assert!(rpc_url.starts_with("ws:"));
229
230    let provider = alloy_provider::ProviderBuilder::new()
231        .with_recommended_fillers()
232        .wallet(wallet)
233        .on_ws(WsConnect::new(rpc_url))
234        .await?;
235
236    let chain_id = provider.get_chain_id().await?;
237    eprintln!("Chain ID: {chain_id}");
238
239    for (kind, name, contract) in contracts {
240        eprintln!("Deploying contract: {name} ...");
241        let Some(bytecode) = contract.bytecode.clone() else {
242            eprintln!("Contract {name} does not have deployed bytecode! Skipping ...");
243            continue;
244        };
245        let tx = alloy_rpc_types::TransactionRequest::default().with_deploy_code(bytecode);
246        // Deploy the contract.
247        let receipt = provider.send_transaction(tx).await?.get_receipt().await?;
248        // Check the receipt status.
249        if receipt.status() {
250            let contract_address =
251                alloy_network::ReceiptResponse::contract_address(&receipt).unwrap();
252            eprintln!("Contract {name} deployed at: {contract_address}");
253            match kind {
254                ContractKind::Manager => {
255                    blueprint.manager = BlueprintManager::Evm(contract_address.to_string());
256                }
257            }
258        } else {
259            eprintln!("Contract {name} deployment failed!");
260            eprintln!("Receipt: {receipt:#?}");
261        }
262    }
263    Ok(())
264}
265
266/// Checks if the contracts need to be built and builds them if needed.
267fn build_contracts_if_needed(
268    package: &cargo_metadata::Package,
269    blueprint: &ServiceBlueprint,
270) -> Result<()> {
271    let pathes_to_check = match blueprint.manager {
272        BlueprintManager::Evm(ref path) => vec![path],
273        _ => return Err(Error::UnsupportedBlueprintManager.into()),
274    };
275
276    let abs_pathes_to_check: Vec<_> = pathes_to_check
277        .into_iter()
278        .map(|path| resolve_path_relative_to_package(package, path))
279        .collect();
280
281    let needs_build = abs_pathes_to_check.iter().any(|path| !path.exists());
282
283    if needs_build {
284        let foundry = crate::foundry::FoundryToolchain::new();
285        foundry.check_installed_or_exit();
286        foundry.forge.build()?;
287    }
288
289    Ok(())
290}
291
292/// Converts the ServiceBlueprint to a format that can be sent to the Tangle Network.
293fn bake_blueprint(
294    blueprint: ServiceBlueprint,
295) -> Result<TangleApi::runtime_types::tangle_primitives::services::ServiceBlueprint> {
296    let mut blueprint_json = serde_json::to_value(&blueprint)?;
297    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["name"]);
298    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["description"]);
299    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["author"]);
300    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["license"]);
301    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["website"]);
302    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["code_repository"]);
303    convert_to_bytes_or_null(&mut blueprint_json["metadata"]["category"]);
304
305    // Set the Hooks to be empty to be compatible with the old blueprint format.
306    // This is because the new blueprint format has manager field instead of different hooks.
307    blueprint_json["registration_hook"] = serde_json::json!("None");
308    blueprint_json["request_hook"] = serde_json::json!("None");
309    for job in blueprint_json["jobs"].as_array_mut().unwrap() {
310        convert_to_bytes_or_null(&mut job["metadata"]["name"]);
311        convert_to_bytes_or_null(&mut job["metadata"]["description"]);
312        // Set an empty verifier to be compatible with the old blueprint format.
313        job["verifier"] = serde_json::json!("None");
314    }
315
316    // Retrieves the Gadget information from the blueprint.json file.
317    // From this data, we find the sources of the Gadget.
318    let (_, gadget) = blueprint_json["gadget"]
319        .as_object_mut()
320        .expect("Bad gadget value")
321        .iter_mut()
322        .next()
323        .expect("Should be at least one gadget");
324    let sources = gadget["sources"].as_array_mut().expect("Should be a list");
325
326    // The source includes where to fetch the Blueprint, the name of the blueprint, and
327    // the name of the binary. From this data, we create the blueprint's [`ServiceBlueprint`]
328    for (idx, source) in sources.iter_mut().enumerate() {
329        let Some(fetchers) = source["fetcher"].as_object_mut() else {
330            return Err(Error::MissingFetcher(idx).into());
331        };
332
333        let fetcher_fields = fetchers
334            .iter_mut()
335            .next()
336            .expect("Should be at least one fetcher")
337            .1;
338        for (_key, value) in fetcher_fields
339            .as_object_mut()
340            .expect("Fetcher should be a map")
341        {
342            if value.is_array() {
343                let xs = value.as_array_mut().expect("Value should be an array");
344                for x in xs {
345                    if x.is_object() {
346                        convert_to_bytes_or_null(&mut x["name"]);
347                    }
348                }
349            } else {
350                convert_to_bytes_or_null(value);
351            }
352        }
353    }
354
355    let blueprint = serde_json::from_value(blueprint_json)?;
356    Ok(blueprint)
357}
358
359/// Recursively converts a JSON string (or array of JSON strings) to bytes.
360///
361/// Empty strings are converted to nulls.
362fn convert_to_bytes_or_null(v: &mut serde_json::Value) {
363    if let serde_json::Value::String(s) = v {
364        *v = serde_json::Value::Array(s.bytes().map(serde_json::Value::from).collect());
365        return;
366    }
367
368    if let serde_json::Value::Array(vals) = v {
369        for val in vals {
370            convert_to_bytes_or_null(val);
371        }
372    }
373}
374
375/// Resolves a path relative to the package manifest.
376fn resolve_path_relative_to_package(
377    package: &cargo_metadata::Package,
378    path: &str,
379) -> std::path::PathBuf {
380    if path.starts_with('/') {
381        std::path::PathBuf::from(path)
382    } else {
383        package.manifest_path.parent().unwrap().join(path).into()
384    }
385}
386
387/// Finds a package in the workspace to deploy.
388fn find_package<'m>(
389    metadata: &'m cargo_metadata::Metadata,
390    pkg_name: Option<&String>,
391) -> Result<&'m cargo_metadata::Package, eyre::Error> {
392    match metadata.workspace_members.len() {
393        0 => Err(Error::NoPackageFound.into()),
394        1 => metadata
395            .packages
396            .iter()
397            .find(|p| p.id == metadata.workspace_members[0])
398            .ok_or(Error::NoPackageFound.into()),
399        _more_than_one if pkg_name.is_some() => metadata
400            .packages
401            .iter()
402            .find(|p| pkg_name.is_some_and(|v| &p.name == v))
403            .ok_or(Error::NoPackageFound.into()),
404        _otherwise => {
405            eprintln!("Please specify the package to deploy:");
406            for package in metadata.packages.iter() {
407                eprintln!("Found: {}", package.name);
408            }
409            eprintln!();
410            Err(Error::ManyPackages.into())
411        }
412    }
413}