diff --git a/crates/contracts/core/Cargo.toml b/crates/contracts/core/Cargo.toml index 4427634..63dc24f 100644 --- a/crates/contracts/core/Cargo.toml +++ b/crates/contracts/core/Cargo.toml @@ -2,6 +2,7 @@ name = "stellaraid-core" version = "0.1.0" edition = "2021" +license = "MIT" [lib] crate-type = ["cdylib"] diff --git a/crates/contracts/core/src/lib.rs b/crates/contracts/core/src/lib.rs index 2f15743..921619a 100644 --- a/crates/contracts/core/src/lib.rs +++ b/crates/contracts/core/src/lib.rs @@ -16,6 +16,7 @@ impl CoreContract { #[cfg(test)] mod tests { use super::*; + use soroban_sdk::testutils::Address as _; use soroban_sdk::Env; #[test] @@ -24,7 +25,7 @@ mod tests { let contract_id = env.register_contract(None, CoreContract); let client = CoreContractClient::new(&env, &contract_id); - let admin = env.current_contract_address(); + let admin = Address::generate(&env); client.init(&admin); let result = client.ping(); diff --git a/crates/contracts/core/test_snapshots/tests/test_init_and_ping.1.json b/crates/contracts/core/test_snapshots/tests/test_init_and_ping.1.json index a9d81fe..f554dc6 100644 --- a/crates/contracts/core/test_snapshots/tests/test_init_and_ping.1.json +++ b/crates/contracts/core/test_snapshots/tests/test_init_and_ping.1.json @@ -1,9 +1,12 @@ { "generators": { - "address": 1, + "address": 2, "nonce": 0 }, - "auth": [], + "auth": [ + [], + [] + ], "ledger": { "protocol_version": 21, "sequence_number": 0, @@ -79,22 +82,44 @@ "v0": { "topics": [ { - "symbol": "error" + "symbol": "fn_call" }, { - "error": { - "context": "internal_error" - } + "bytes": "0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "symbol": "init" } ], "data": { - "string": "Current context has no contract ID" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" } } } }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "init" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -104,16 +129,38 @@ "v0": { "topics": [ { - "symbol": "error" + "symbol": "fn_call" }, { - "error": { - "context": "internal_error" - } + "bytes": "0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "symbol": "ping" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "ping" } ], "data": { - "string": "escalating error to panic" + "u32": 1 } } } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 6ade18f..8cdcf0c 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -2,6 +2,7 @@ name = "stellaraid-tools" version = "0.1.0" edition = "2021" +license = "MIT" [[bin]] name = "stellaraid-cli" @@ -17,6 +18,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.7" thiserror = "1.0" +stellar-baselib = "0.5.6" [dev-dependencies] tempfile = "3" diff --git a/crates/tools/src/config.rs b/crates/tools/src/config.rs index 99bebee..f34311f 100644 --- a/crates/tools/src/config.rs +++ b/crates/tools/src/config.rs @@ -177,6 +177,7 @@ mod tests { use super::*; use std::fs::File; use std::io::Write; + use std::sync::{Mutex, OnceLock}; use tempfile::tempdir; fn write_toml(dir: &Path, content: &str) -> PathBuf { @@ -192,83 +193,99 @@ mod tests { env::remove_var("SOROBAN_NETWORK_PASSPHRASE"); } + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn with_isolated_env(f: F) { + let _guard = env_lock().lock().expect("env lock poisoned"); + clear_env_vars(); + f(); + clear_env_vars(); + } + #[test] fn loads_profile_by_name() { - let d = tempdir().unwrap(); - let toml = r#" + with_isolated_env(|| { + let d = tempdir().unwrap(); + let toml = r#" [profile.testnet] network = "testnet" rpc_url = "https://soroban-testnet.stellar.org" network_passphrase = "Test SDF Network ; September 2015" "#; - clear_env_vars(); - let p = write_toml(d.path(), toml); - env::set_var("SOROBAN_NETWORK", "testnet"); - - let cfg = Config::load(Some(&p)).expect("should load"); - assert_eq!(cfg.profile, "testnet"); - assert_eq!(cfg.rpc_url, "https://soroban-testnet.stellar.org"); - assert_eq!(cfg.network_passphrase, "Test SDF Network ; September 2015"); - match cfg.network { - Network::Testnet => {}, - _ => panic!("expected testnet"), - } + let p = write_toml(d.path(), toml); + env::set_var("SOROBAN_NETWORK", "testnet"); + + let cfg = Config::load(Some(&p)).expect("should load"); + assert_eq!(cfg.profile, "testnet"); + assert_eq!(cfg.rpc_url, "https://soroban-testnet.stellar.org"); + assert_eq!(cfg.network_passphrase, "Test SDF Network ; September 2015"); + match cfg.network { + Network::Testnet => {}, + _ => panic!("expected testnet"), + } + }); } #[test] fn env_overrides_profile_values() { - let d = tempdir().unwrap(); - let toml = r#" + with_isolated_env(|| { + let d = tempdir().unwrap(); + let toml = r#" [profile.testnet] network = "testnet" rpc_url = "https://soroban-testnet.stellar.org" network_passphrase = "Test SDF Network ; September 2015" "#; - clear_env_vars(); - let p = write_toml(d.path(), toml); - env::set_var("SOROBAN_NETWORK", "testnet"); - env::set_var("SOROBAN_RPC_URL", "https://override.local"); - env::set_var("SOROBAN_NETWORK_PASSPHRASE", "override pass"); - - let cfg = Config::load(Some(&p)).expect("should load with overrides"); - assert_eq!(cfg.rpc_url, "https://override.local"); - assert_eq!(cfg.network_passphrase, "override pass"); + let p = write_toml(d.path(), toml); + env::set_var("SOROBAN_NETWORK", "testnet"); + env::set_var("SOROBAN_RPC_URL", "https://override.local"); + env::set_var("SOROBAN_NETWORK_PASSPHRASE", "override pass"); + + let cfg = Config::load(Some(&p)).expect("should load with overrides"); + assert_eq!(cfg.rpc_url, "https://override.local"); + assert_eq!(cfg.network_passphrase, "override pass"); + }); } #[test] fn missing_required_values_returns_error() { - let d = tempdir().unwrap(); - // create a profile with empty values - let toml = r#" + with_isolated_env(|| { + let d = tempdir().unwrap(); + // create a profile with empty values + let toml = r#" [profile.empty] network = "" rpc_url = "" network_passphrase = "" "#; - clear_env_vars(); - let p = write_toml(d.path(), toml); + let p = write_toml(d.path(), toml); - // ensure defaulting behavior picks testnet is not present -> should error - let res = Config::load(Some(&p)); - assert!(res.is_err()); + // ensure defaulting behavior picks testnet is not present -> should error + let res = Config::load(Some(&p)); + assert!(res.is_err()); + }); } #[test] fn loads_sandbox_profile() { - let d = tempdir().unwrap(); - let toml = r#" + with_isolated_env(|| { + let d = tempdir().unwrap(); + let toml = r#" [profile.sandbox] network = "sandbox" rpc_url = "http://localhost:8000" network_passphrase = "Standalone Network ; February 2017" "#; - clear_env_vars(); - let p = write_toml(d.path(), toml); + let p = write_toml(d.path(), toml); - env::set_var("SOROBAN_NETWORK", "sandbox"); + env::set_var("SOROBAN_NETWORK", "sandbox"); - let cfg = Config::load(Some(&p)).expect("should load sandbox"); - assert_eq!(cfg.profile, "sandbox"); - assert_eq!(cfg.rpc_url, "http://localhost:8000"); + let cfg = Config::load(Some(&p)).expect("should load sandbox"); + assert_eq!(cfg.profile, "sandbox"); + assert_eq!(cfg.rpc_url, "http://localhost:8000"); + }); } } diff --git a/crates/tools/src/donation_tx_builder.rs b/crates/tools/src/donation_tx_builder.rs new file mode 100644 index 0000000..bf671cf --- /dev/null +++ b/crates/tools/src/donation_tx_builder.rs @@ -0,0 +1,326 @@ +use thiserror::Error; + +use stellar_baselib::account::{Account, AccountBehavior}; +use stellar_baselib::asset::{Asset, AssetBehavior}; +use stellar_baselib::operation::{Operation, ONE}; +use stellar_baselib::transaction::TransactionBehavior; +use stellar_baselib::transaction_builder::{TransactionBuilder, TransactionBuilderBehavior}; +use stellar_baselib::xdr; +use stellar_baselib::xdr::WriteXdr; + +#[derive(Debug, Clone)] +pub struct BuildDonationTxRequest { + pub donor_address: String, + pub donor_sequence: String, + pub platform_address: String, + pub donation_amount: String, + pub asset_code: String, + pub asset_issuer: Option, + pub project_id: String, + pub network_passphrase: String, + pub timeout_seconds: i64, + pub base_fee_stroops: u32, +} + +#[derive(Debug, Clone)] +pub struct BuildDonationTxResult { + pub xdr: String, + pub memo: String, + pub fee: u32, + pub amount_stroops: i64, + pub asset: String, + pub destination: String, +} + +#[derive(Debug, Error)] +pub enum BuildDonationTxError { + #[error("invalid donor account: {0}")] + InvalidDonorAccount(String), + #[error("invalid destination account: {0}")] + InvalidDestinationAccount(String), + #[error("invalid amount '{0}' (must be positive and have at most 7 decimals)")] + InvalidAmount(String), + #[error("invalid asset: {0}")] + InvalidAsset(String), + #[error("project ID cannot be empty")] + EmptyProjectId, + #[error("memo is too long for Stellar text memo (max 28 bytes): '{0}'")] + MemoTooLong(String), + #[error("memo must be ASCII text")] + MemoNotAscii, + #[error("timeout must be non-negative")] + InvalidTimeout, + #[error("transaction build failed: {0}")] + BuildFailed(String), +} + +pub fn build_donation_transaction( + request: BuildDonationTxRequest, +) -> Result { + if request.timeout_seconds < 0 { + return Err(BuildDonationTxError::InvalidTimeout); + } + + let project_id = request.project_id.trim(); + if project_id.is_empty() { + return Err(BuildDonationTxError::EmptyProjectId); + } + + let memo = format!("project_{project_id}"); + if !memo.is_ascii() { + return Err(BuildDonationTxError::MemoNotAscii); + } + if memo.len() > 28 { + return Err(BuildDonationTxError::MemoTooLong(memo)); + } + + let amount_stroops = parse_amount_to_stroops(&request.donation_amount)?; + let asset = parse_asset(&request.asset_code, request.asset_issuer.as_deref())?; + + let mut source_account = Account::new(&request.donor_address, &request.donor_sequence) + .map_err(BuildDonationTxError::InvalidDonorAccount)?; + + let payment = Operation::new() + .payment(&request.platform_address, &asset, amount_stroops) + .map_err(|e| match e { + stellar_baselib::operation::Error::InvalidField(_) => { + BuildDonationTxError::InvalidDestinationAccount(request.platform_address.clone()) + }, + stellar_baselib::operation::Error::InvalidAmount(_) => { + BuildDonationTxError::InvalidAmount(request.donation_amount.clone()) + }, + stellar_baselib::operation::Error::InvalidPrice(_, _) => { + BuildDonationTxError::BuildFailed( + "unexpected invalid price for payment op".to_string(), + ) + }, + })?; + + let mut tx_builder = + TransactionBuilder::new(&mut source_account, &request.network_passphrase, None); + tx_builder + .fee(request.base_fee_stroops) + .add_operation(payment) + .add_memo(&memo) + .set_timeout(request.timeout_seconds) + .map_err(BuildDonationTxError::BuildFailed)?; + + let transaction = tx_builder.build(); + let envelope = transaction + .to_envelope() + .map_err(|e| BuildDonationTxError::BuildFailed(e.to_string()))?; + + let xdr = envelope + .to_xdr_base64(xdr::Limits::none()) + .map_err(|e| BuildDonationTxError::BuildFailed(e.to_string()))?; + + let fee = request.base_fee_stroops; + + Ok(BuildDonationTxResult { + xdr, + memo, + fee, + amount_stroops, + asset: asset.to_string_asset(), + destination: request.platform_address, + }) +} + +fn parse_asset(code: &str, issuer: Option<&str>) -> Result { + if code.eq_ignore_ascii_case("XLM") { + return Ok(Asset::native()); + } + + let issuer = issuer + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + BuildDonationTxError::InvalidAsset( + "issuer is required for non-native assets (e.g., USDC)".to_string(), + ) + })?; + + Asset::new(code, Some(issuer)).map_err(BuildDonationTxError::InvalidAsset) +} + +fn parse_amount_to_stroops(amount: &str) -> Result { + let trimmed = amount.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + return Err(BuildDonationTxError::InvalidAmount(amount.to_string())); + } + + let normalized = if let Some(stripped) = trimmed.strip_prefix('+') { + stripped + } else { + trimmed + }; + + let mut parts = normalized.split('.'); + let whole_part = parts.next().unwrap_or("0"); + let fractional_part = parts.next().unwrap_or(""); + if parts.next().is_some() { + return Err(BuildDonationTxError::InvalidAmount(amount.to_string())); + } + + if !whole_part.is_empty() + && !whole_part + .chars() + .all(|character| character.is_ascii_digit()) + { + return Err(BuildDonationTxError::InvalidAmount(amount.to_string())); + } + if !fractional_part + .chars() + .all(|character| character.is_ascii_digit()) + || fractional_part.len() > 7 + { + return Err(BuildDonationTxError::InvalidAmount(amount.to_string())); + } + + let whole_value = if whole_part.is_empty() { + 0 + } else { + whole_part + .parse::() + .map_err(|_| BuildDonationTxError::InvalidAmount(amount.to_string()))? + }; + + let mut fractional_string = fractional_part.to_string(); + while fractional_string.len() < 7 { + fractional_string.push('0'); + } + let fractional_value = if fractional_string.is_empty() { + 0 + } else { + fractional_string + .parse::() + .map_err(|_| BuildDonationTxError::InvalidAmount(amount.to_string()))? + }; + + let stroops = whole_value + .checked_mul(ONE) + .and_then(|value| value.checked_add(fractional_value)) + .ok_or_else(|| BuildDonationTxError::InvalidAmount(amount.to_string()))?; + + if stroops <= 0 { + return Err(BuildDonationTxError::InvalidAmount(amount.to_string())); + } + + Ok(stroops) +} + +#[cfg(test)] +mod tests { + use super::*; + use stellar_baselib::keypair::{Keypair, KeypairBehavior}; + use stellar_baselib::xdr::ReadXdr; + + fn sample_request(asset_code: &str, asset_issuer: Option) -> BuildDonationTxRequest { + let donor = Keypair::random().unwrap().public_key(); + let destination = Keypair::random().unwrap().public_key(); + + BuildDonationTxRequest { + donor_address: donor, + donor_sequence: "100".to_string(), + platform_address: destination, + donation_amount: "12.3456789".to_string(), + asset_code: asset_code.to_string(), + asset_issuer, + project_id: "123".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + timeout_seconds: 300, + base_fee_stroops: 100, + } + } + + #[test] + fn builds_native_xlm_transaction() { + let request = sample_request("XLM", None); + let result = build_donation_transaction(request).expect("native tx should build"); + + assert_eq!(result.memo, "project_123"); + assert_eq!(result.fee, 100); + assert_eq!(result.amount_stroops, 123_456_789); + assert_eq!(result.asset, "native"); + assert!(!result.xdr.is_empty()); + + let envelope = + xdr::TransactionEnvelope::from_xdr_base64(&result.xdr, xdr::Limits::none()).unwrap(); + match envelope { + xdr::TransactionEnvelope::Tx(envelope) => { + assert_eq!(envelope.tx.fee, 100); + assert_eq!(envelope.tx.operations.len(), 1); + match envelope.tx.memo { + xdr::Memo::Text(text) => assert_eq!(text.to_string(), "project_123"), + _ => panic!("expected text memo"), + } + + match &envelope.tx.operations[0].body { + xdr::OperationBody::Payment(payment) => { + assert_eq!(payment.amount, 123_456_789); + assert!(matches!(payment.asset, xdr::Asset::Native)); + }, + _ => panic!("expected payment operation"), + } + }, + _ => panic!("expected tx envelope"), + } + } + + #[test] + fn builds_credit_asset_transaction() { + let issuer = Keypair::random().unwrap().public_key(); + let request = sample_request("USDC", Some(issuer)); + let result = build_donation_transaction(request).expect("credit tx should build"); + + assert_eq!(result.memo, "project_123"); + assert_eq!(result.fee, 100); + assert!(result.asset.starts_with("USDC:")); + + let envelope = + xdr::TransactionEnvelope::from_xdr_base64(&result.xdr, xdr::Limits::none()).unwrap(); + match envelope { + xdr::TransactionEnvelope::Tx(envelope) => match &envelope.tx.operations[0].body { + xdr::OperationBody::Payment(payment) => { + assert!(matches!(payment.asset, xdr::Asset::CreditAlphanum4(_))); + }, + _ => panic!("expected payment operation"), + }, + _ => panic!("expected tx envelope"), + } + } + + #[test] + fn builds_credit_asset_12_transaction() { + let issuer = Keypair::random().unwrap().public_key(); + let request = sample_request("TOKENASSET12", Some(issuer)); + let result = build_donation_transaction(request).expect("credit-12 tx should build"); + + let envelope = + xdr::TransactionEnvelope::from_xdr_base64(&result.xdr, xdr::Limits::none()).unwrap(); + match envelope { + xdr::TransactionEnvelope::Tx(envelope) => match &envelope.tx.operations[0].body { + xdr::OperationBody::Payment(payment) => { + assert!(matches!(payment.asset, xdr::Asset::CreditAlphanum12(_))); + }, + _ => panic!("expected payment operation"), + }, + _ => panic!("expected tx envelope"), + } + } + + #[test] + fn rejects_non_native_asset_without_issuer() { + let request = sample_request("USDC", None); + let error = build_donation_transaction(request).unwrap_err(); + assert!(matches!(error, BuildDonationTxError::InvalidAsset(_))); + } + + #[test] + fn rejects_invalid_amount_precision() { + let mut request = sample_request("XLM", None); + request.donation_amount = "1.12345678".to_string(); + let error = build_donation_transaction(request).unwrap_err(); + assert!(matches!(error, BuildDonationTxError::InvalidAmount(_))); + } +} diff --git a/crates/tools/src/main.rs b/crates/tools/src/main.rs index dccf80f..a58de91 100644 --- a/crates/tools/src/main.rs +++ b/crates/tools/src/main.rs @@ -6,7 +6,9 @@ use std::path::PathBuf; use std::process::Command; mod config; +mod donation_tx_builder; use config::{Config, Network}; +use donation_tx_builder::{build_donation_transaction, BuildDonationTxRequest}; const CONTRACT_ID_FILE: &str = ".stellaraid_contract_id"; @@ -57,6 +59,39 @@ enum Commands { }, /// Print resolved network configuration Network, + /// Build a donation payment transaction XDR for client-side signing + BuildDonationTx { + /// Donor public key (source account) + #[arg(long)] + donor: String, + /// Current donor account sequence number + #[arg(long)] + donor_sequence: String, + /// Donation amount (up to 7 decimals, e.g. 10.5) + #[arg(long)] + amount: String, + /// Asset code (XLM for native, or token code like USDC) + #[arg(long, default_value = "XLM")] + asset: String, + /// Asset issuer public key (required for non-XLM assets) + #[arg(long)] + issuer: Option, + /// Project ID used in memo as project_ + #[arg(long)] + project_id: String, + /// Destination platform public key (overrides env var) + #[arg(long)] + destination: Option, + /// Transaction timeout in seconds + #[arg(long, default_value_t = 300)] + timeout_seconds: i64, + /// Base fee in stroops per operation + #[arg(long, default_value_t = 100)] + base_fee: u32, + /// Explicit network passphrase (defaults to config value) + #[arg(long)] + network_passphrase: Option, + }, } #[derive(Subcommand)] @@ -75,17 +110,17 @@ fn main() -> Result<()> { skip_init, } => { deploy_contract(&network, wasm.as_deref(), skip_init)?; - } + }, Commands::Invoke { method, args, network, } => { invoke_contract(&method, args.as_deref(), network.as_deref())?; - } + }, Commands::ContractId { network } => { show_contract_id(network.as_deref())?; - } + }, Commands::Config { action } => match action { ConfigAction::Check => { println!("Checking configuration..."); @@ -94,18 +129,22 @@ fn main() -> Result<()> { println!("✅ Configuration valid!"); println!(" Network: {}", cfg.network); println!(" RPC URL: {}", cfg.rpc_url); - println!(" Admin Key: {}", cfg.admin_key.map_or("Not set".to_string(), |_| "Configured".to_string())); - } + println!( + " Admin Key: {}", + cfg.admin_key + .map_or("Not set".to_string(), |_| "Configured".to_string()) + ); + }, Err(e) => { eprintln!("❌ Configuration error: {}", e); std::process::exit(1); - } + }, } - } + }, ConfigAction::Init => { println!("Initializing configuration..."); initialize_config()?; - } + }, }, Commands::Network => match Config::load(None) { Ok(cfg) => { @@ -115,17 +154,110 @@ fn main() -> Result<()> { if let Some(key) = cfg.admin_key { println!("Admin Key: {}", key); } - } + }, Err(e) => { eprintln!("Failed to load config: {}", e); std::process::exit(2); - } + }, + }, + Commands::BuildDonationTx { + donor, + donor_sequence, + amount, + asset, + issuer, + project_id, + destination, + timeout_seconds, + base_fee, + network_passphrase, + } => { + build_donation_tx( + &donor, + &donor_sequence, + &amount, + &asset, + issuer.as_deref(), + &project_id, + destination.as_deref(), + timeout_seconds, + base_fee, + network_passphrase.as_deref(), + )?; }, } Ok(()) } +fn resolve_platform_public_key(destination_override: Option<&str>) -> Result { + if let Some(destination) = destination_override { + return Ok(destination.to_string()); + } + + env::var("STELLARAID_PLATFORM_PUBLIC_KEY") + .or_else(|_| env::var("PLATFORM_PUBLIC_KEY")) + .context( + "Missing destination account. Pass --destination or set STELLARAID_PLATFORM_PUBLIC_KEY", + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_donation_tx( + donor: &str, + donor_sequence: &str, + amount: &str, + asset: &str, + issuer: Option<&str>, + project_id: &str, + destination_override: Option<&str>, + timeout_seconds: i64, + base_fee: u32, + network_passphrase_override: Option<&str>, +) -> Result<()> { + let destination = resolve_platform_public_key(destination_override)?; + + let network_passphrase = if let Some(passphrase) = network_passphrase_override { + passphrase.to_string() + } else { + Config::load(None) + .map(|cfg| cfg.network_passphrase) + .context( + "Failed to resolve network passphrase from config. Pass --network-passphrase or configure soroban.toml", + )? + }; + + let request = BuildDonationTxRequest { + donor_address: donor.to_string(), + donor_sequence: donor_sequence.to_string(), + platform_address: destination, + donation_amount: amount.to_string(), + asset_code: asset.to_string(), + asset_issuer: issuer.map(ToString::to_string), + project_id: project_id.to_string(), + network_passphrase, + timeout_seconds, + base_fee_stroops: base_fee, + }; + + match build_donation_transaction(request) { + Ok(result) => { + println!("✅ Donation transaction built successfully"); + println!(" Destination: {}", result.destination); + println!(" Asset: {}", result.asset); + println!(" Amount (stroops): {}", result.amount_stroops); + println!(" Memo: {}", result.memo); + println!(" Fee (stroops): {}", result.fee); + println!(" XDR (ready for signing): {}", result.xdr); + Ok(()) + }, + Err(err) => { + eprintln!("❌ Failed to build donation transaction: {}", err); + std::process::exit(1); + }, + } +} + /// Get the path to the WASM file fn get_wasm_path(custom_path: Option<&str>) -> Result { if let Some(path) = custom_path { @@ -141,7 +273,9 @@ fn get_wasm_path(custom_path: Option<&str>) -> Result { PathBuf::from("target/wasm32-unknown-unknown/debug/stellaraid_core.wasm"), PathBuf::from("target/wasm32-unknown-unknown/release/stellaraid_core.wasm"), PathBuf::from("contracts/core/target/wasm32-unknown-unknown/debug/stellaraid_core.wasm"), - PathBuf::from("crates/contracts/core/target/wasm32-unknown-unknown/debug/stellaraid_core.wasm"), + PathBuf::from( + "crates/contracts/core/target/wasm32-unknown-unknown/debug/stellaraid_core.wasm", + ), ]; for p in &default_paths { @@ -157,19 +291,17 @@ fn get_wasm_path(custom_path: Option<&str>) -> Result { return Ok(wasm_path); } - anyhow::bail!( - "WASM file not found. Build with 'make wasm' or specify with --wasm flag" - ) + anyhow::bail!("WASM file not found. Build with 'make wasm' or specify with --wasm flag") } /// Store the contract ID in a local file fn store_contract_id(contract_id: &str, network: &str) -> Result<()> { let cwd = env::current_dir()?; let file_path = cwd.join(CONTRACT_ID_FILE); - + let content = if file_path.exists() { - let existing: serde_json::Value = serde_json::from_str(&fs::read_to_string(&file_path)?) - .unwrap_or(serde_json::json!({})); + let existing: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&file_path)?).unwrap_or(serde_json::json!({})); let mut map = serde_json::Map::new(); if let Some(obj) = existing.as_object() { for (k, v) in obj { @@ -191,22 +323,24 @@ fn store_contract_id(contract_id: &str, network: &str) -> Result<()> { fn load_contract_id(network: &str) -> Result { let cwd = env::current_dir()?; let file_path = cwd.join(CONTRACT_ID_FILE); - + if !file_path.exists() { - anyhow::bail!( - "No contract ID found. Deploy a contract first with 'deploy' command" - ); + anyhow::bail!("No contract ID found. Deploy a contract first with 'deploy' command"); } let content: serde_json::Value = serde_json::from_str(&fs::read_to_string(&file_path)?)?; - + if let Some(id) = content.get(network).and_then(|v| v.as_str()) { Ok(id.to_string()) } else { + let available = content + .as_object() + .map(|obj| obj.keys().cloned().collect::>().join(", ")) + .unwrap_or_else(|| "none".to_string()); anyhow::bail!( "No contract ID found for network '{}'. Available: {}", network, - content.keys().collect::>().join(", ") + available ); } } @@ -214,24 +348,28 @@ fn load_contract_id(network: &str) -> Result { /// Deploy the contract to the specified network fn deploy_contract(network: &str, wasm_path: Option<&str>, skip_init: bool) -> Result<()> { println!("🚀 Deploying to network: {}", network); - + // Load configuration env::set_var("SOROBAN_NETWORK", network); let config = Config::load(None).context("Failed to load configuration")?; - + // Get WASM path let wasm = get_wasm_path(wasm_path)?; println!("đŸ“Ļ Using WASM: {}", wasm.display()); - + // Build soroban deploy command let output = Command::new("soroban") .args([ "contract", "deploy", - "--wasm", wasm.to_str().unwrap(), - "--network", network, - "--rpc-url", &config.rpc_url, - "--network-passphrase", &config.network_passphrase, + "--wasm", + wasm.to_str().unwrap(), + "--network", + network, + "--rpc-url", + &config.rpc_url, + "--network-passphrase", + &config.network_passphrase, ]) .output() .context("Failed to execute soroban CLI")?; @@ -244,13 +382,13 @@ fn deploy_contract(network: &str, wasm_path: Option<&str>, skip_init: bool) -> R let stdout = String::from_utf8_lossy(&output.stdout); let contract_id = stdout.trim(); - + println!("✅ Contract deployed successfully!"); println!("📝 Contract ID: {}", contract_id); - + // Store contract ID store_contract_id(contract_id, network)?; - + // Initialize the contract if needed if !skip_init { if let Some(admin_key) = &config.admin_key { @@ -259,13 +397,17 @@ fn deploy_contract(network: &str, wasm_path: Option<&str>, skip_init: bool) -> R .args([ "contract", "invoke", - "--network", network, - "--rpc-url", &config.rpc_url, - "--network-passphrase", &config.network_passphrase, + "--network", + network, + "--rpc-url", + &config.rpc_url, + "--network-passphrase", + &config.network_passphrase, contract_id, "--", "init", - "--admin", admin_key, + "--admin", + admin_key, ]) .output() .context("Failed to initialize contract")?; @@ -281,7 +423,7 @@ fn deploy_contract(network: &str, wasm_path: Option<&str>, skip_init: bool) -> R println!(" Set SOROBAN_ADMIN_KEY environment variable to initialize the contract."); } } - + Ok(()) } @@ -303,42 +445,45 @@ fn invoke_contract(method: &str, args: Option<&str>, network_override: Option<&s "testnet".to_string() } }; - + println!("🔄 Invoking method '{}' on network: {}", method, network); - + // Load configuration env::set_var("SOROBAN_NETWORK", &network); let config = Config::load(None).context("Failed to load configuration")?; - + // Load contract ID let contract_id = load_contract_id(&network)?; println!("📝 Using contract ID: {}", contract_id); - + // Build invoke command let mut cmd_args = vec![ - "contract", - "invoke", - "--network", &network, - "--rpc-url", &config.rpc_url, - "--network-passphrase", &config.network_passphrase, - &contract_id, - "--", - method, + "contract".to_string(), + "invoke".to_string(), + "--network".to_string(), + network.clone(), + "--rpc-url".to_string(), + config.rpc_url.clone(), + "--network-passphrase".to_string(), + config.network_passphrase.clone(), + contract_id.clone(), + "--".to_string(), + method.to_string(), ]; - + // Add arguments if provided if let Some(arguments) = args { // Parse JSON arguments and add them - let parsed: serde_json::Value = serde_json::from_str(arguments) - .context("Failed to parse arguments as JSON")?; - + let parsed: serde_json::Value = + serde_json::from_str(arguments).context("Failed to parse arguments as JSON")?; + if let Some(arr) = parsed.as_array() { for val in arr { - cmd_args.push(&val.to_string()); + cmd_args.push(val.to_string()); } } } - + let output = Command::new("soroban") .args(&cmd_args) .output() @@ -353,7 +498,7 @@ fn invoke_contract(method: &str, args: Option<&str>, network_override: Option<&s let stdout = String::from_utf8_lossy(&output.stdout); println!("✅ Invocation successful!"); println!("📤 Result: {}", stdout.trim()); - + Ok(()) } @@ -366,14 +511,14 @@ fn show_contract_id(network_override: Option<&str>) -> Result<()> { // Show all stored contract IDs let cwd = env::current_dir()?; let file_path = cwd.join(CONTRACT_ID_FILE); - + if !file_path.exists() { println!("No contract IDs stored. Deploy a contract first."); return Ok(()); } - + let content: serde_json::Value = serde_json::from_str(&fs::read_to_string(&file_path)?)?; - + println!("Stored contract IDs:"); if let Some(obj) = content.as_object() { for (network, id) in obj { @@ -387,14 +532,14 @@ fn show_contract_id(network_override: Option<&str>) -> Result<()> { /// Initialize configuration files fn initialize_config() -> Result<()> { let cwd = env::current_dir()?; - + // Check if .env already exists let env_path = cwd.join(".env"); if env_path.exists() { println!("âš ī¸ .env file already exists"); return Ok(()); } - + // Create .env file with example values let env_content = r#"# StellarAid Configuration # Network: testnet, mainnet, or sandbox @@ -410,11 +555,11 @@ SOROBAN_NETWORK=testnet # Use 'soroban keys generate' to create a new key # SOROBAN_ADMIN_KEY= "#; - + fs::write(&env_path, env_content)?; println!("✅ Created .env file"); println!("â„šī¸ Edit .env to configure your network and admin key"); - + // Check if contract ID file exists let contract_path = cwd.join(CONTRACT_ID_FILE); if !contract_path.exists() { @@ -422,6 +567,6 @@ SOROBAN_NETWORK=testnet fs::write(&contract_path, serde_json::to_string_pretty(&empty)?)?; println!("✅ Created {} file", CONTRACT_ID_FILE); } - + Ok(()) } diff --git a/deny.toml b/deny.toml index fa02d21..4eb7141 100644 --- a/deny.toml +++ b/deny.toml @@ -1,43 +1,34 @@ # cargo-deny configuration [advisories] -version = 2 -vulnerability = "deny" -unmaintained = "warn" -unsound = "warn" -notice = "warn" +unmaintained = "workspace" +unsound = "workspace" ignore = [] [bans] -version = 2 +multiple-versions = "warn" +wildcards = "allow" deny = [] skip = [] skip-tree = [] [licenses] -version = 2 -unlicensed = "deny" allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "ISC", "CC0-1.0", "Zlib", "OpenSSL", + "Unicode-3.0", + "LGPL-3.0-or-later", ] -deny = [ - "GPL-1.0", - "GPL-2.0", - "GPL-3.0", - "AGPL-3.0", -] -copyleft = "warn" confidence-threshold = 0.8 [sources] -version = 2 unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"]