diff --git a/app/onchain/contracts/aid_escrow/Cargo.toml b/app/onchain/contracts/aid_escrow/Cargo.toml index 523d3f666..7adfee2e6 100644 --- a/app/onchain/contracts/aid_escrow/Cargo.toml +++ b/app/onchain/contracts/aid_escrow/Cargo.toml @@ -18,3 +18,4 @@ soroban-sdk = { workspace = true, features = ["testutils"] } ed25519-dalek = "2.2" serde_json = "1.0" rand = "0.8" +serde = { version = "1.0", features = ["derive"] } diff --git a/app/onchain/contracts/aid_escrow/GAS_BUDGETS.json b/app/onchain/contracts/aid_escrow/GAS_BUDGETS.json new file mode 100644 index 000000000..17dfe0682 --- /dev/null +++ b/app/onchain/contracts/aid_escrow/GAS_BUDGETS.json @@ -0,0 +1,16 @@ +{ + "tolerance_percent": 5.0, + "budgets": { + "Single create_package": { "cpu": 87508, "memory": 17667 }, + "Batch create_packages (size: 10)": { "cpu": 584710, "memory": 123066 }, + "Batch create_packages (size: 25)": { "cpu": 1958279, "memory": 442401 }, + "Batch create_packages (size: 50)": { "cpu": 5559545, "memory": 1350626 }, + "Batch create_packages (size: 100)": { "cpu": 17573866, "memory": 4577076 }, + "Batch create_packages (size: 200)": { "cpu": 60633076, "memory": 16669976 }, + "Single claim": { "cpu": 78143, "memory": 11020 }, + "Claim with Merkle proof": { "cpu": 157326, "memory": 18945 }, + "Fund operation (1 token)": { "cpu": 110627, "memory": 16179 }, + "Get package": { "cpu": 20000, "memory": 8000 }, + "Get aggregates (50 packages)": { "cpu": 2000000, "memory": 500000 } + } +} diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index e691d90eb..efd8c74ff 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -989,6 +989,162 @@ impl AidEscrow { Ok(()) } + /// Admin-only. Batch revoke packages. + /// Accepts a bounded list of package ids and revokes each one. + /// Behavior: atomic validation first (rejects if any package is in an invalid state), + /// then applies revocations. Already-cancelled packages are treated as idempotent successes. + pub fn batch_revoke(env: Env, ids: Vec) -> Result, Error> { + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + // Validation pass: ensure all packages exist and are revokable (Created or already Cancelled) + for i in 0..ids.len() { + let id = ids.get(i).unwrap(); + let key = (symbol_short!("pkg"), id); + let package: Package = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PackageNotFound)?; + + match package.status { + PackageStatus::Created | PackageStatus::Cancelled => {} + _ => return Err(Error::InvalidState), + } + } + + let mut ok_ids: Vec = Vec::new(&env); + + // Apply pass: perform revocations for packages that are still Created. + for i in 0..ids.len() { + let id = ids.get(i).unwrap(); + let key = (symbol_short!("pkg"), id); + let mut package: Package = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PackageNotFound)?; + + if package.status == PackageStatus::Created { + package.status = PackageStatus::Cancelled; + env.storage().persistent().set(&key, &package); + + // Unlock funds + Self::decrement_locked(&env, &package.token, package.amount); + + let timestamp = env.ledger().timestamp(); + PackageRevoked { + package_id: id, + recipient: package.recipient.clone(), + amount: package.amount, + actor: admin.clone(), + timestamp, + } + .publish(&env); + } + + ok_ids.push_back(id); + } + + Ok(ok_ids) + } + + /// Admin-only. Batch refund packages. + /// Accepts a bounded list of package ids and refunds each one to admin. + /// Atomic validation first: rejects the whole call if any package is in an invalid state + /// (e.g., Claimed). Already-refunded packages are treated as idempotent successes. + pub fn batch_refund(env: Env, ids: Vec) -> Result, Error> { + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + let now = env.ledger().timestamp(); + + // Validation pass + for i in 0..ids.len() { + let id = ids.get(i).unwrap(); + let key = (symbol_short!("pkg"), id); + let package: Package = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PackageNotFound)?; + + // Determine acceptable statuses: Cancelled, Expired, Created(if actually expired), Refunded (idempotent) + match package.status { + PackageStatus::Created => { + // Created packages can only be refunded if actually expired. + if package.expires_at == 0 || now <= package.expires_at { + return Err(Error::InvalidState); + } + } + PackageStatus::Expired | PackageStatus::Cancelled | PackageStatus::Refunded => { + // ok (Refunded is idempotent) + } + _ => return Err(Error::InvalidState), + } + } + + let mut ok_ids: Vec = Vec::new(&env); + + // Apply pass: perform transfers and state updates for packages not already Refunded + for i in 0..ids.len() { + let id = ids.get(i).unwrap(); + let key = (symbol_short!("pkg"), id); + let mut package: Package = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PackageNotFound)?; + + if package.status == PackageStatus::Refunded { + ok_ids.push_back(id); + continue; + } + + // If Created but expired, reflect that before transfer + let mut should_unlock_locked = false; + if package.status == PackageStatus::Created { + // At this point validation already ensured it's expired + package.status = PackageStatus::Expired; + should_unlock_locked = true; + } else if package.status == PackageStatus::Expired { + should_unlock_locked = true; + } else if package.status == PackageStatus::Cancelled { + should_unlock_locked = false; + } + + // Transfer Contract -> Admin + Self::transfer_token( + &env, + &package.token, + &env.current_contract_address(), + &admin, + &package.amount, + )?; + + if should_unlock_locked { + Self::decrement_locked(&env, &package.token, package.amount); + } + + package.status = PackageStatus::Refunded; + env.storage().persistent().set(&key, &package); + + let timestamp = env.ledger().timestamp(); + PackageRefunded { + package_id: id, + recipient: package.recipient.clone(), + amount: package.amount, + actor: admin.clone(), + timestamp, + } + .publish(&env); + + ok_ids.push_back(id); + } + + Ok(ok_ids) + } + /// Admin-only package cancellation. /// Requirements: Admin auth, existing package, status must be 'Created'. pub fn cancel_package(env: Env, package_id: u64) -> Result<(), Error> { diff --git a/app/onchain/contracts/aid_escrow/tests/batch.rs b/app/onchain/contracts/aid_escrow/tests/batch.rs index 6f849ecc0..bdc2319df 100644 --- a/app/onchain/contracts/aid_escrow/tests/batch.rs +++ b/app/onchain/contracts/aid_escrow/tests/batch.rs @@ -216,3 +216,84 @@ fn test_batch_create_packages_empty_arrays() { ); assert_eq!(ids.len(), 0); } + +#[test] +fn test_batch_revoke_and_idempotent() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + let recipient2 = Address::generate(&env); + let token_admin = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &token_admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(10 * UNIT)); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient1.clone()); + recipients.push_back(recipient2.clone()); + + let mut amounts = Vec::new(&env); + amounts.push_back(UNIT); + amounts.push_back(UNIT); + + let ids = client.batch_create_packages(&admin, &recipients, &amounts, &token_client.address, &86400, &empty_metadata(&env, 2)); + + // Revoke both in batch + let revoked = client.batch_revoke(&ids); + assert_eq!(revoked.len(), 2); + assert_eq!(client.get_package(&ids.get(0).unwrap()).status, aid_escrow::PackageStatus::Cancelled); + + // Second invocation should be idempotent (already cancelled) + let revoked2 = client.batch_revoke(&ids); + assert_eq!(revoked2.len(), 2); +} + +#[test] +fn test_batch_refund_and_partial_failure() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + let recipient2 = Address::generate(&env); + let token_admin = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &token_admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(10 * UNIT)); + + // create two packages, one we'll claim so refund batch should fail validation + let mut recipients = Vec::new(&env); + recipients.push_back(recipient1.clone()); + recipients.push_back(recipient2.clone()); + + let mut amounts = Vec::new(&env); + amounts.push_back(UNIT); + amounts.push_back(UNIT); + + let ids = client.batch_create_packages(&admin, &recipients, &amounts, &token_client.address, &1, &empty_metadata(&env, 2)); + + // Claim the second package to make it ineligible for refund + client.claim(&ids.get(1).unwrap()); + + // Attempt batch_refund should error because one package is Claimed + let result = client.try_batch_refund(&ids); + assert!(result.is_err()); + + // Now revoke first package, then refund just first package successfully + client.batch_revoke(&Vec::from_array(&env, &[ids.get(0).unwrap().clone()])); + let single = Vec::from_array(&env, &[ids.get(0).unwrap().clone()]); + let refunded = client.batch_refund(&single); + assert_eq!(refunded.len(), 1); +} diff --git a/app/onchain/contracts/aid_escrow/tests/gas_profiling.rs b/app/onchain/contracts/aid_escrow/tests/gas_profiling.rs index 77023fb89..224d3a5e9 100644 --- a/app/onchain/contracts/aid_escrow/tests/gas_profiling.rs +++ b/app/onchain/contracts/aid_escrow/tests/gas_profiling.rs @@ -6,6 +6,8 @@ use soroban_sdk::{ token::StellarAssetClient, Address, Env, Map, Vec, }; +use serde::Deserialize; +use std::{collections::HashMap, fs, path::Path}; // --------------------------------------------------------------------------- // Constants for 7-decimal tokens (Standard Stellar Asset) @@ -101,6 +103,67 @@ fn print_budget_metrics(operation: &str, metrics: &BudgetMetrics) { println!(); } +#[derive(Deserialize)] +struct BudgetEntry { + cpu: u64, + memory: u64, +} + +#[derive(Deserialize)] +struct BudgetsFile { + tolerance_percent: f64, + budgets: HashMap, +} + +fn load_budgets() -> Option { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let path = Path::new(manifest_dir).join("GAS_BUDGETS.json"); + match fs::read_to_string(&path) { + Ok(s) => match serde_json::from_str::(&s) { + Ok(b) => Some(b), + Err(e) => { + println!("Failed to parse budgets file {}: {}", path.display(), e); + None + } + }, + Err(e) => { + println!("Failed to read budgets file {}: {}", path.display(), e); + None + } + } +} + +fn assert_within_budget(operation: &str, metrics: &BudgetMetrics) { + if let Some(budgets) = load_budgets() { + if let Some(entry) = budgets.budgets.get(operation) { + let tol = budgets.tolerance_percent / 100.0; + let allowed_cpu = (entry.cpu as f64 * (1.0 + tol)).ceil() as u64; + let allowed_mem = (entry.memory as f64 * (1.0 + tol)).ceil() as u64; + + if metrics.cpu_instructions > allowed_cpu { + let delta = metrics.cpu_instructions.saturating_sub(entry.cpu); + panic!( + "Gas budget exceeded for '{}': CPU used {} (budget {}), delta {}", + operation, metrics.cpu_instructions, entry.cpu, delta + ); + } + + if metrics.memory_bytes > allowed_mem { + let delta = metrics.memory_bytes.saturating_sub(entry.memory); + panic!( + "Gas budget exceeded for '{}': Memory used {} (budget {}), delta {}", + operation, metrics.memory_bytes, entry.memory, delta + ); + } + } else { + // No budget committed for this operation; skip gating but warn. + println!("No budget entry for '{}', skipping budget assertion", operation); + } + } else { + println!("No budgets loaded; skipping budget assertions"); + } +} + // =========================================================================== // Gas Profiling Tests // =========================================================================== @@ -140,6 +203,7 @@ fn profile_single_create_package() { }; print_budget_metrics("Single create_package", &metrics); + assert_within_budget("Single create_package", &metrics); } #[test] @@ -202,10 +266,9 @@ fn profile_batch_create(batch_size: u32) { memory_bytes: after.memory_bytes.saturating_sub(before.memory_bytes), }; - print_budget_metrics( - &format!("Batch create_packages (size: {})", batch_size), - &metrics, - ); + let op_name = format!("Batch create_packages (size: {})", batch_size); + print_budget_metrics(&op_name, &metrics); + assert_within_budget(&op_name, &metrics); // Calculate per-package metrics let per_package_cpu = metrics.cpu_instructions / batch_size as u64; @@ -287,6 +350,7 @@ fn profile_single_claim() { }; print_budget_metrics("Single claim", &metrics); + assert_within_budget("Single claim", &metrics); } #[test] @@ -410,6 +474,7 @@ fn profile_claim_with_proof() { }; print_budget_metrics("Claim with Merkle proof", &metrics); + assert_within_budget("Claim with Merkle proof", &metrics); } #[test] @@ -436,6 +501,7 @@ fn profile_fund_operation() { }; print_budget_metrics("Fund operation (1 token)", &metrics); + assert_within_budget("Fund operation (1 token)", &metrics); } #[test] @@ -474,6 +540,7 @@ fn profile_get_package() { }; print_budget_metrics("Get package", &metrics); + assert_within_budget("Get package", &metrics); } #[test] @@ -514,8 +581,7 @@ fn profile_get_aggregates() { memory_bytes: after.memory_bytes.saturating_sub(before.memory_bytes), }; - print_budget_metrics( - &format!("Get aggregates ({} packages)", batch_size), - &metrics, - ); + let agg_name = format!("Get aggregates ({} packages)", batch_size); + print_budget_metrics(&agg_name, &metrics); + assert_within_budget(&agg_name, &metrics); }