use anyhow::{Context, Result};
use apex_sdk_types::Chain;
use colored::Colorize;
use std::path::Path;
pub async fn deploy_contract(
contract_path: &str,
chain: &str,
endpoint: &str,
account_name: Option<String>,
dry_run: bool,
) -> Result<()> {
let is_substrate = Chain::is_substrate_endpoint(endpoint)
|| Chain::from_str_case_insensitive(chain)
.map(|c| c.chain_type() == apex_sdk_types::ChainType::Substrate)
.unwrap_or(false);
if is_substrate {
deploy_substrate_contract(contract_path, chain, endpoint, account_name, dry_run).await
} else {
deploy_evm_contract(contract_path, chain, endpoint, account_name, dry_run).await
}
}
async fn deploy_substrate_contract(
contract_path: &str,
chain: &str,
endpoint: &str,
account_name: Option<String>,
dry_run: bool,
) -> Result<()> {
use sp_core::{crypto::Ss58Codec, sr25519, Pair};
use subxt::{OnlineClient, PolkadotConfig};
let title = if dry_run {
"Dry-Run: Substrate WASM Contract Deployment"
} else {
"Deploying Substrate WASM Contract"
};
println!("\n{}", title.cyan().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!("{}: {}", "Contract".dimmed(), contract_path);
println!("{}: {}", "Chain".dimmed(), chain);
println!("{}: {}", "Endpoint".dimmed(), endpoint);
if dry_run {
println!(
"{}: DRY RUN - No transaction will be broadcast",
"Mode".yellow().bold()
);
}
println!();
let path = Path::new(contract_path);
if !path.exists() {
anyhow::bail!("Contract file not found: {}", contract_path);
}
let extension = path.extension().and_then(|s| s.to_str());
match extension {
Some("contract") | Some("wasm") => {}
Some(ext) => {
println!(
"{}",
format!("Warning: Expected .contract or .wasm file, got .{}", ext).yellow()
);
}
None => {
anyhow::bail!("Contract file must have .contract or .wasm extension");
}
}
const MAX_CONTRACT_SIZE: u64 = 10 * 1024 * 1024; let metadata =
std::fs::metadata(contract_path).context("Failed to read contract file metadata")?;
if metadata.len() > MAX_CONTRACT_SIZE {
anyhow::bail!(
"Contract file too large: {} bytes (max {} MB). \
Consider optimizing your contract or splitting functionality.",
metadata.len(),
MAX_CONTRACT_SIZE / (1024 * 1024)
);
}
let spinner = indicatif::ProgressBar::new_spinner();
spinner.set_message("Reading contract file...");
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
let contract_code = std::fs::read(contract_path).context("Failed to read contract file")?;
spinner.set_message(format!("Contract size: {} bytes", contract_code.len()));
let (signer_name, mnemonic) = if let Some(name) = account_name {
spinner.set_message(format!("Loading account '{}'...", name));
let password = rpassword::prompt_password("Enter account password: ")
.context("Failed to read password")?;
let keystore_path = crate::keystore::get_keystore_path()?;
let mut keystore = crate::keystore::Keystore::load(&keystore_path)?;
let mnemonic_bytes = keystore.get_account(&name, &password)?;
let mnemonic = String::from_utf8(mnemonic_bytes).context("Failed to decode mnemonic")?;
(name, mnemonic)
} else {
spinner.finish_and_clear();
anyhow::bail!(
"Account required for deployment.\n\n\
Use --account flag to specify an account:\n \
apex deploy {} --chain {} --endpoint {} --account <name>\n\n\
Or create an account first:\n \
apex account generate --type substrate",
contract_path,
chain,
endpoint
);
};
spinner.set_message("Connecting to chain...");
let api = OnlineClient::<PolkadotConfig>::from_url(endpoint)
.await
.context("Failed to connect to Substrate endpoint")?;
let mnemonic_obj: bip39::Mnemonic = mnemonic.parse().context("Invalid mnemonic phrase")?;
let seed = mnemonic_obj.to_seed("");
let pair = sr25519::Pair::from_seed_slice(&seed[..32])
.map_err(|e| anyhow::anyhow!("Failed to generate keypair: {:?}", e))?;
let signer_address = pair.public().to_ss58check();
spinner.finish_with_message(format!("Connected with account: {}", signer_name));
println!("\n{}", "Deployment Summary".cyan().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!(
"{}: {} bytes",
"Contract Size".dimmed(),
contract_code.len()
);
println!("{}: {}", "Deployer".dimmed(), signer_name);
println!("{}: {}", "Address".dimmed(), signer_address);
let metadata = api.metadata();
let has_contracts = metadata.pallet_by_name("Contracts").is_some();
if !has_contracts {
anyhow::bail!(
"Chain '{}' does not have the Contracts pallet enabled.\n\n\
Substrate contract deployment requires a chain with the Contracts pallet.\n\
Supported chains include:\n\
-Contracts on Rococo (testnet)\n\
-Astar\n\
-Shiden\n\
-Custom chains with pallet-contracts",
chain
);
}
println!("{}: Available", "Contracts Pallet".green());
if dry_run {
println!("\n{}", "Dry-Run Validation Complete".green().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!("All validation checks passed:");
println!(" - Contract file is valid");
println!(" - Connected to chain");
println!(" - Contracts pallet is available");
println!(" - Account is ready");
println!();
println!("{}", "Ready for Real Deployment".cyan().bold());
println!("To perform the actual deployment, run the same command without --dry-run:");
println!(
" apex deploy {} --chain {} --endpoint {} --account {}",
contract_path, chain, endpoint, signer_name
);
println!();
println!("{}", "Note:".yellow());
println!("Substrate contract deployment will:");
println!(" -Upload WASM code to the chain");
println!(" -Instantiate the contract");
println!(" -Spend fees from your account");
} else {
println!("\n{}", "Ready to Deploy".yellow().bold());
println!("This will upload and instantiate the contract on chain.");
print!("\nProceed with deployment? (yes/no): ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "yes" {
println!("\n{}", "Deployment cancelled.".yellow());
return Ok(());
}
println!("\n{}", "Uploading contract code...".cyan());
let upload_call = subxt::dynamic::tx(
"Contracts",
"upload_code",
vec![
subxt::dynamic::Value::from_bytes(contract_code.clone()),
subxt::dynamic::Value::unnamed_variant("None", vec![]), subxt::dynamic::Value::unnamed_variant("Enforced", vec![]), ],
);
let seed_bytes: [u8; 32] = seed[..32]
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid seed length"))?;
let signer = subxt_signer::sr25519::Keypair::from_secret_key(seed_bytes)
.map_err(|e| anyhow::anyhow!("Failed to create signer: {:?}", e))?;
let tx_progress = api
.tx()
.sign_and_submit_then_watch_default(&upload_call, &signer)
.await
.context("Failed to submit upload_code transaction")?;
let tx_hash = tx_progress.extrinsic_hash();
println!("{}: {}", "Transaction Hash".cyan(), tx_hash);
println!("{}", "Waiting for finalization...".yellow());
let events = tx_progress
.wait_for_finalized_success()
.await
.context("Transaction failed or was not finalized")?;
println!("\n{}", "Contract Code Uploaded Successfully".green().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!("{}: {}", "Extrinsic Hash".cyan(), events.extrinsic_hash());
println!("{}: {} bytes", "Code Size".dimmed(), contract_code.len());
let code_hash = format!(
"0x{}",
hex::encode(&contract_code[..32.min(contract_code.len())])
);
println!("{}: {}", "Code Hash (approx)".dimmed(), code_hash);
println!("\n{}", "Next Steps:".cyan());
println!(" - Use Polkadot.js Apps to instantiate the contract");
println!(" - Or use cargo-contract for full deployment workflow");
println!(" - Contract code is now stored on-chain");
println!("\n{}", "Resources:".cyan());
println!(" -Polkadot.js Apps: https://polkadot.js.org/apps/");
println!(" -cargo-contract: https://github.com/paritytech/cargo-contract");
}
Ok(())
}
async fn deploy_evm_contract(
contract_path: &str,
chain: &str,
endpoint: &str,
account_name: Option<String>,
dry_run: bool,
) -> Result<()> {
use apex_sdk_evm::{wallet::Wallet, EvmAdapter};
let title = if dry_run {
"Dry-Run: EVM Contract Deployment"
} else {
"Deploying EVM Contract"
};
println!("\n{}", title.cyan().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!("{}: {}", "Contract".dimmed(), contract_path);
println!("{}: {}", "Chain".dimmed(), chain);
println!("{}: {}", "Endpoint".dimmed(), endpoint);
if dry_run {
println!(
"{}: DRY RUN - No transaction will be broadcast",
"Mode".yellow().bold()
);
}
println!();
let path = Path::new(contract_path);
if !path.exists() {
anyhow::bail!("Contract file not found: {}", contract_path);
}
const MAX_CONTRACT_SIZE: u64 = 50 * 1024 * 1024; let metadata =
std::fs::metadata(contract_path).context("Failed to read contract file metadata")?;
if metadata.len() > MAX_CONTRACT_SIZE {
anyhow::bail!(
"Contract file too large: {} bytes (max {} MB). \
Consider optimizing your contract.",
metadata.len(),
MAX_CONTRACT_SIZE / (1024 * 1024)
);
}
let extension = path.extension().and_then(|s| s.to_str());
let contract_data = match extension {
Some("bin") | Some("hex") => {
let code = std::fs::read_to_string(contract_path)
.context("Failed to read contract bytecode")?;
hex::decode(code.trim().trim_start_matches("0x")).context("Invalid hex bytecode")?
}
Some("json") => {
let json_str =
std::fs::read_to_string(contract_path).context("Failed to read contract JSON")?;
let json: serde_json::Value =
serde_json::from_str(&json_str).context("Invalid JSON file")?;
let bytecode_hex = json
.get("bytecode")
.or_else(|| json.get("data"))
.or_else(|| json.get("object"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Could not find bytecode in JSON file"))?;
hex::decode(bytecode_hex.trim().trim_start_matches("0x"))
.context("Invalid hex bytecode in JSON")?
}
Some(ext) => {
anyhow::bail!(
"Unsupported contract file extension: .{}\nSupported: .bin, .hex, .json",
ext
);
}
None => {
anyhow::bail!("Contract file must have an extension (.bin, .hex, or .json)");
}
};
let spinner = indicatif::ProgressBar::new_spinner();
spinner.set_message(format!("Contract bytecode: {} bytes", contract_data.len()));
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
let (signer_name, mnemonic) = if let Some(name) = account_name {
spinner.set_message(format!("Loading account '{}'...", name));
let password = rpassword::prompt_password("Enter account password: ")
.context("Failed to read password")?;
let keystore_path = crate::keystore::get_keystore_path()?;
let mut keystore = crate::keystore::Keystore::load(&keystore_path)?;
let mnemonic_bytes = keystore.get_account(&name, &password)?;
let mnemonic = String::from_utf8(mnemonic_bytes).context("Failed to decode mnemonic")?;
(name, mnemonic)
} else {
spinner.finish_and_clear();
anyhow::bail!(
"Account required for deployment.\n\n\
Use --account flag to specify an account:\n \
apex deploy {} --chain {} --endpoint {} --account <name>\n\n\
Or create an account first:\n \
apex account generate --type evm",
contract_path,
chain,
endpoint
);
};
spinner.set_message("Connecting to chain...");
let adapter = EvmAdapter::connect(endpoint)
.await
.context("Failed to connect to EVM endpoint")?;
let wallet =
Wallet::from_mnemonic(&mnemonic, 0).context("Failed to create wallet from mnemonic")?;
let chain_id = adapter.provider().chain_id();
let wallet = wallet.with_chain_id(chain_id);
spinner.set_message("Estimating gas...");
use alloy::providers::Provider;
let provider = adapter.provider();
let gas_price = provider
.provider
.get_gas_price()
.await
.context("Failed to get gas price")?;
let base_gas = 21000u64; let bytecode_gas = (contract_data.len() as u64) * 200; let gas_estimate = base_gas + bytecode_gas + 50000;
spinner.finish_and_clear();
println!("\n{}", "Deployment Summary".cyan().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!(
"{}: {} bytes",
"Bytecode Size".dimmed(),
contract_data.len()
);
println!("{}: {}", "Deployer".dimmed(), signer_name);
println!("{}: {}", "From Address".dimmed(), wallet.address());
println!("{}: {}", "Chain ID".dimmed(), chain_id);
println!("{}: {}", "Gas Estimate".dimmed(), gas_estimate);
let gas_price_gwei = gas_price as f64 / 1e9;
println!("{}: {:.2} gwei", "Gas Price".dimmed(), gas_price_gwei);
let estimated_cost_wei = (gas_estimate as u128) * gas_price;
let estimated_cost_eth = estimated_cost_wei as f64 / 1e18;
println!(
"{}: {:.6} ETH",
"Est. Cost".yellow().bold(),
estimated_cost_eth
);
if dry_run {
println!("\n{}", "Dry-Run Validation Complete".green().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!("All validation checks passed:");
println!(" - Contract file is valid");
println!(" - Connected to chain");
println!(" - Account is ready");
println!(" - Gas estimation successful");
println!(" - Transaction can be constructed");
println!();
println!("{}", "Ready for Real Deployment".cyan().bold());
println!("To perform the actual deployment, run the same command without --dry-run:");
println!(
" apex deploy {} --chain {} --endpoint {} --account {}",
contract_path, chain, endpoint, signer_name
);
println!();
println!("{}", "Note:".yellow());
println!("The actual deployment will:");
println!(" -Sign the transaction with your private key");
println!(" -Broadcast to the network");
println!(" -Wait for confirmation");
println!(" -Spend ~{:.6} ETH in gas fees", estimated_cost_eth);
} else {
println!("\n{}", "Ready to Deploy".yellow().bold());
println!("This will spend gas fees from your account.");
print!("\nProceed with deployment? (yes/no): ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "yes" {
println!("\n{}", "Deployment cancelled.".yellow());
return Ok(());
}
println!("\n{}", "Broadcasting transaction...".cyan());
use alloy::network::TransactionBuilder;
use alloy::primitives::{Bytes, U256};
use alloy::rpc::types::TransactionRequest;
let from_address: alloy::primitives::Address = wallet
.address()
.to_string()
.parse()
.context("Failed to parse wallet address")?;
let nonce = provider
.provider
.get_transaction_count(from_address)
.await
.context("Failed to get nonce")?;
let tx_request = TransactionRequest::default()
.with_from(from_address)
.with_chain_id(chain_id)
.with_nonce(nonce)
.with_gas_limit(gas_estimate)
.with_gas_price(gas_price)
.with_value(U256::ZERO)
.with_input(Bytes::from(contract_data.clone()));
use alloy::providers::Provider;
let pending_tx = provider
.provider
.send_transaction(tx_request)
.await
.context("Failed to send deployment transaction")?;
let tx_hash = format!("0x{:x}", *pending_tx.tx_hash());
println!("{}: {}", "Transaction Hash".cyan(), &tx_hash);
println!("{}", "Waiting for confirmation...".yellow());
let receipt = pending_tx
.get_receipt()
.await
.context("Failed to get transaction receipt")?;
let contract_address = receipt
.contract_address
.map(|addr| format!("0x{:x}", addr))
.unwrap_or_else(|| "N/A".to_string());
let block_number = receipt.block_number.unwrap_or(0);
let actual_gas_used = receipt.gas_used;
let effective_gas_price = receipt.effective_gas_price;
if !receipt.status() {
println!("\n{}", "Deployment Failed!".red().bold());
println!("{}: Transaction reverted", "Error".red());
return Err(anyhow::anyhow!("Contract deployment transaction reverted"));
}
println!("\n{}", "Deployment Successful".green().bold());
println!("{}", "═══════════════════════════════════════".dimmed());
println!(
"{}: {}",
"Contract Address".green().bold(),
contract_address
);
println!("{}: {}", "Transaction Hash".cyan(), tx_hash);
println!("{}: {}", "Block Number".dimmed(), block_number);
println!("{}: {}", "Gas Used".dimmed(), actual_gas_used);
let gas_used = actual_gas_used as u128;
let actual_cost_wei = gas_used * (effective_gas_price as u128);
let actual_cost_eth = format_wei_to_eth(actual_cost_wei);
println!("{}: {} ETH", "Actual Cost".yellow(), actual_cost_eth);
println!("\n{}", "Next Steps:".cyan());
println!(" -Verify contract on block explorer");
println!(" -Save contract address for future interactions");
println!(" -Test contract functions");
}
Ok(())
}
use std::io::Write;
fn format_wei_to_eth(wei: u128) -> String {
let eth_divisor = 10_u128.pow(18);
let eth_whole = wei / eth_divisor;
let remainder = wei % eth_divisor;
if remainder == 0 {
format!("{}", eth_whole)
} else {
let formatted = format!("{}.{:018}", eth_whole, remainder);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_detect_chain_type_substrate() {
assert!(is_substrate_endpoint("wss://polkadot.api.onfinality.io"));
assert!(is_substrate_endpoint("ws://localhost:9944"));
assert!(is_substrate_endpoint("wss://kusama-rpc.polkadot.io"));
assert!(is_substrate_endpoint("ws://127.0.0.1:9944"));
}
#[test]
fn test_detect_chain_type_evm() {
assert!(!is_substrate_endpoint("https://eth.llamarpc.com"));
assert!(!is_substrate_endpoint("http://localhost:8545"));
assert!(!is_substrate_endpoint("https://mainnet.infura.io/v3/key"));
assert!(!is_substrate_endpoint("https://bsc-dataseed.binance.org"));
}
#[test]
fn test_hex_decode_valid() {
let test_cases = [
"0x6080604052",
"6080604052",
"0x",
"",
"0xdeadbeef",
"deadbeef",
"0x00",
"00",
];
for hex_str in &test_cases {
let stripped = hex_str.trim_start_matches("0x");
if !stripped.is_empty() {
let decoded = hex::decode(stripped);
assert!(decoded.is_ok(), "Failed to decode valid hex: {}", hex_str);
}
}
}
#[test]
fn test_hex_decode_invalid() {
let invalid_cases = [
"0xghi", "hello", "0x123", "123", ];
for hex_str in &invalid_cases {
let stripped = hex_str.trim_start_matches("0x");
let decoded = hex::decode(stripped);
assert!(
decoded.is_err(),
"Expected invalid hex to fail: {}",
hex_str
);
}
}
#[test]
fn test_format_gas_price() {
let test_cases = [
(0u128, "0"),
(1_000_000_000u128, "1"), (1_500_000_000u128, "1.5"), (10_000_000_000u128, "10"), (20_000_000_000u128, "20"), (999_999_999u128, "0.999999999"), ];
for (wei, expected) in &test_cases {
let result = format_gas_price(*wei);
assert_eq!(
result, *expected,
"Failed for {} wei, expected {}, got {}",
wei, expected, result
);
}
}
#[test]
fn test_format_gas_price_edge_cases() {
assert_eq!(format_gas_price(1), "0.000000001");
assert_eq!(format_gas_price(10), "0.00000001");
assert_eq!(format_gas_price(1_000_000_000), "1");
assert_eq!(format_gas_price(2_000_000_000), "2");
assert_eq!(format_gas_price(100_000_000_000), "100");
}
#[tokio::test]
async fn test_deploy_contract_missing_file() {
let result = deploy_contract(
"/nonexistent/contract.wasm",
"polkadot",
"wss://polkadot.api.onfinality.io",
None,
true, )
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_deploy_contract_invalid_chain() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("contract.wasm");
fs::write(&file_path, b"fake contract").unwrap();
let result = deploy_contract(
file_path.to_str().unwrap(),
"invalid_chain",
"https://invalid.endpoint",
None,
true, )
.await;
assert!(result.is_err());
}
#[test]
fn test_chain_type_detection_from_name() {
use apex_sdk_types::{Chain, ChainType};
if let Some(chain) = Chain::from_str_case_insensitive("polkadot") {
assert_eq!(chain.chain_type(), ChainType::Substrate);
}
if let Some(chain) = Chain::from_str_case_insensitive("kusama") {
assert_eq!(chain.chain_type(), ChainType::Substrate);
}
if let Some(chain) = Chain::from_str_case_insensitive("ethereum") {
assert_eq!(chain.chain_type(), ChainType::Evm);
}
if let Some(chain) = Chain::from_str_case_insensitive("polygon") {
assert_eq!(chain.chain_type(), ChainType::Evm);
}
}
#[test]
fn test_file_path_validation() {
let valid_paths = [
"contract.wasm",
"./contract.wasm",
"/path/to/contract.wasm",
"contracts/MyContract.wasm",
];
for path in &valid_paths {
let path_obj = Path::new(path);
assert!(
path_obj.to_str().is_some(),
"Failed to process path: {}",
path
);
}
}
#[test]
fn test_contract_extension_validation() {
let wasm_files = ["contract.wasm", "MyContract.wasm", "path/to/contract.wasm"];
let json_files = ["contract.json", "MyContract.json", "path/to/metadata.json"];
for file in &wasm_files {
let path = Path::new(file);
if let Some(ext) = path.extension() {
assert_eq!(ext, "wasm", "Expected .wasm extension for {}", file);
}
}
for file in &json_files {
let path = Path::new(file);
if let Some(ext) = path.extension() {
assert_eq!(ext, "json", "Expected .json extension for {}", file);
}
}
}
#[test]
fn test_account_name_validation() {
let valid_names = ["Ilara", "bob", "charlie", "my-account", "account_123"];
for name in &valid_names {
assert!(
!name.is_empty(),
"Account name should not be empty: {}",
name
);
assert!(
name.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
"Account name should contain valid characters: {}",
name
);
}
}
#[tokio::test]
#[ignore] async fn test_dry_run_flag() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("contract.wasm");
fs::write(&file_path, b"fake contract").unwrap();
let result = deploy_contract(
file_path.to_str().unwrap(),
"polkadot",
"wss://invalid.endpoint", Some("Ilara".to_string()),
true, )
.await;
let _ = result; }
#[test]
fn test_deployment_parameters_validation() {
let contract_path = "contract.wasm";
let chain = "polkadot";
let endpoint = "wss://polkadot.api.onfinality.io";
assert!(!contract_path.is_empty());
assert!(!chain.is_empty());
assert!(!endpoint.is_empty());
assert!(
endpoint.starts_with("wss://")
|| endpoint.starts_with("ws://")
|| endpoint.starts_with("https://")
|| endpoint.starts_with("http://")
);
}
fn is_substrate_endpoint(endpoint: &str) -> bool {
endpoint.starts_with("ws://") || endpoint.starts_with("wss://")
}
fn format_gas_price(wei: u128) -> String {
let gwei_divisor = 1_000_000_000u128;
let gwei_whole = wei / gwei_divisor;
let remainder = wei % gwei_divisor;
if remainder == 0 {
format!("{}", gwei_whole)
} else {
let formatted = format!("{}.{:09}", gwei_whole, remainder);
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
}