diff --git a/contracts/atomic_swap/src/cross_contract_tests.rs b/contracts/atomic_swap/src/cross_contract_tests.rs index 1e7dd2c..52e6a28 100644 --- a/contracts/atomic_swap/src/cross_contract_tests.rs +++ b/contracts/atomic_swap/src/cross_contract_tests.rs @@ -1,6 +1,25 @@ -/// Unit tests for cross-contract failure attribution. +/// Unit tests for cross-contract failure attribution and end-to-end lifecycle +/// tests spanning ip_registry and atomic_swap. /// -/// Covers: +/// # #833: Full-lifecycle integration tests +/// +/// The integration section covers the complete IP-sale lifecycle: +/// commit → initiate_swap → accept_swap → reveal_key → Completed +/// +/// Each test deploys both contracts in the Soroban test environment so that +/// the cross-contract calls made by atomic_swap into ip_registry execute +/// against real contract WASM, exercising both contracts' state consistently. +/// +/// Additional tests cover negative paths: +/// - A swap referencing a revoked IP must be rejected at initiation. +/// - IP ownership does NOT change through atomic_swap (the swap contract +/// does not call ip_registry.transfer_ip); the registry owner record is +/// unchanged by the swap completion. Buyers who want formal ownership +/// transfer must call ip_registry.transfer_ip separately. +/// +/// # #832: Failure attribution unit tests +/// +/// The attribution section covers: /// 1. Successful cross-contract execution — no failure, result is `Ok`. /// 2. Single contract failure — `FailureAttribution` names the failing contract /// and the call chain is empty. @@ -8,12 +27,326 @@ /// an outer contract carries the full call chain in outermost→innermost order. #[cfg(test)] mod cross_contract_tests { - use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String}; + use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, String}; use crate::cross_contract::{ attribute_failure, propagate_failure, CrossContractResult, FailureAttribution, }; + // ── #833: Full-lifecycle cross-contract integration tests ───────────────── + + use ip_registry::{IpRegistry, IpRegistryClient}; + use soroban_sdk::token::StellarAssetClient; + + use crate::{AtomicSwap, AtomicSwapClient, ContractError, SwapStatus}; + + // ── helpers ─────────────────────────────────────────────────────────────── + + /// Build a Pedersen-style commitment: SHA-256(secret || blinding_factor). + fn make_commitment(env: &Env, secret: &BytesN<32>, blinding: &BytesN<32>) -> BytesN<32> { + let mut preimage = Bytes::new(env); + preimage.append(&Bytes::from(secret.clone())); + preimage.append(&Bytes::from(blinding.clone())); + env.crypto().sha256(&preimage).into() + } + + /// Deploy and initialise ip_registry. Returns (registry_address, ip_id, + /// secret, blinding_factor). + fn setup_registry( + env: &Env, + owner: &Address, + ) -> (Address, u64, BytesN<32>, BytesN<32>) { + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(env, ®istry_id); + let secret = BytesN::from_array(env, &[0xAAu8; 32]); + let blinding = BytesN::from_array(env, &[0xBBu8; 32]); + let hash = make_commitment(env, &secret, &blinding); + let ip_id = registry.commit_ip(owner, &hash, &0u32); + (registry_id, ip_id, secret, blinding) + } + + /// Create a Stellar Asset token, mint `amount` stroops to `recipient`. + fn setup_token(env: &Env, admin: &Address, recipient: &Address, amount: i128) -> Address { + let token_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + StellarAssetClient::new(env, &token_id).mint(recipient, &amount); + token_id + } + + // ── #833-1: Full happy-path lifecycle ───────────────────────────────────── + + /// commit → initiate_swap → accept_swap → reveal_key → Completed + /// + /// Verifies: + /// - The swap transitions through Pending → Accepted → Completed. + /// - The registry IP record still exists after the swap (ownership is NOT + /// transferred by the swap contract; that requires a separate + /// `ip_registry.transfer_ip` call). + /// - The seller's token balance increases by `price` on completion (net + /// of any fees — zero fees here because no `admin_set_protocol_config` + /// is called). + /// - The active-swap lock on the IP is released (a second swap can be + /// opened after the first completes). + #[test] + fn test_full_lifecycle_commit_initiate_accept_reveal_complete() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let token_admin = Address::generate(&env); + let treasury = Address::generate(&env); + + // 1. Commit IP in ip_registry. + let (registry_id, ip_id, secret, blinding) = setup_registry(&env, &seller); + + // 2. Mint tokens to buyer so they can pay. + let price: i128 = 1_000_000; + let token_id = setup_token(&env, &token_admin, &buyer, price * 2); + + // 3. Deploy and initialise atomic_swap. + let swap_id_addr = env.register(AtomicSwap, ()); + let swap_client = AtomicSwapClient::new(&env, &swap_id_addr); + swap_client.initialize(®istry_id, &treasury); + + // 4. Seller initiates the swap. + let swap_id = swap_client.initiate_swap( + &token_id, + &ip_id, + &seller, + &price, + &buyer, + &0u32, // no approvals required + &None, // no referrer + &0i128, // no collateral + &false, // no insurance + ); + let swap = swap_client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Pending, "swap must start as Pending"); + assert_eq!(swap.ip_id, ip_id); + assert_eq!(swap.seller, seller); + assert_eq!(swap.buyer, buyer); + assert_eq!(swap.price, price); + + // 5. Buyer accepts (funds move into escrow). + swap_client.accept_swap(&swap_id); + let swap = swap_client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Accepted, "swap must be Accepted after buyer pays"); + + // Verify funds left buyer's wallet. + let token_client = soroban_sdk::token::Client::new(&env, &token_id); + let buyer_balance_after_accept = token_client.balance(&buyer); + assert_eq!( + buyer_balance_after_accept, + price, // minted 2×price, spent 1×price into escrow + "buyer must have price_amount left after accepting" + ); + + // 6. Seller reveals the secret + blinding factor. The swap contract + // calls ip_registry.verify_commitment internally to validate the key. + swap_client.reveal_key(&swap_id, &seller, &secret, &blinding); + + // 7. Verify the swap is now Completed. + let swap = swap_client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Completed, "swap must be Completed after reveal"); + + // 8. Verify the seller received the payment (no protocol fee configured). + let seller_balance = token_client.balance(&seller); + assert_eq!( + seller_balance, price, + "seller must receive full price with zero protocol fee" + ); + + // 9. The ip_registry record still exists and is owned by `seller` + // (the swap contract does NOT call transfer_ip — that is a + // separate, explicit step). + let registry_client = IpRegistryClient::new(&env, ®istry_id); + let ip_record = registry_client.get_ip(&ip_id); + assert_eq!( + ip_record.owner, seller, + "ip_registry ownership must not change as a side-effect of the swap" + ); + assert!( + !ip_record.revoked, + "IP must not be revoked by a successful swap" + ); + + // 10. The active-swap lock must be released; a second swap can be opened. + let swap_id_2 = swap_client.initiate_swap( + &token_id, + &ip_id, + &seller, + &price, + &buyer, + &0u32, + &None, + &0i128, + &false, + ); + let swap2 = swap_client.get_swap(&swap_id_2).unwrap(); + assert_eq!( + swap2.status, + SwapStatus::Pending, + "IP must be unlocked after completion, allowing a new swap" + ); + } + + // ── #833-2: Revoked IP rejected at swap initiation ──────────────────────── + + /// A swap for a revoked IP must be rejected at `initiate_swap` with + /// `IpIsRevoked`. Both contracts' states must remain consistent: the + /// ip_registry record is revoked, and no swap record is created. + #[test] + fn test_initiate_swap_for_revoked_ip_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let token_admin = Address::generate(&env); + let treasury = Address::generate(&env); + + // Commit IP. + let (registry_id, ip_id, _secret, _blinding) = setup_registry(&env, &seller); + + let price: i128 = 500_000; + let token_id = setup_token(&env, &token_admin, &buyer, price); + + // Deploy swap contract. + let swap_addr = env.register(AtomicSwap, ()); + let swap_client = AtomicSwapClient::new(&env, &swap_addr); + swap_client.initialize(®istry_id, &treasury); + + // Revoke the IP through ip_registry. + let registry_client = IpRegistryClient::new(&env, ®istry_id); + registry_client.revoke_ip(&ip_id); + + // Confirm revocation. + let ip_record = registry_client.get_ip(&ip_id); + assert!(ip_record.revoked, "IP must be revoked before the swap attempt"); + + // Attempting to initiate a swap must fail with IpIsRevoked. + let result = swap_client.try_initiate_swap( + &token_id, + &ip_id, + &seller, + &price, + &buyer, + &0u32, + &None, + &0i128, + &false, + ); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::IpRevoked.into(), + "swap initiation for a revoked IP must return IpRevoked" + ); + + // No swap record should have been created. + assert!( + swap_client.get_swap(&0u64).is_none(), + "no swap record must exist after a rejected initiation" + ); + } + + // ── #833-3: Ownership record unchanged after swap (explicit assertion) ──── + + /// Verify that after a full lifecycle the registry still records `seller` + /// as the IP owner; the buyer must call `ip_registry.transfer_ip` separately. + #[test] + fn test_registry_ownership_unchanged_after_swap_completion() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let token_admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let (registry_id, ip_id, secret, blinding) = setup_registry(&env, &seller); + let price: i128 = 200_000; + let token_id = setup_token(&env, &token_admin, &buyer, price); + + let swap_addr = env.register(AtomicSwap, ()); + let swap_client = AtomicSwapClient::new(&env, &swap_addr); + swap_client.initialize(®istry_id, &treasury); + + let swap_id = swap_client.initiate_swap( + &token_id, + &ip_id, + &seller, + &price, + &buyer, + &0u32, + &None, + &0i128, + &false, + ); + swap_client.accept_swap(&swap_id); + swap_client.reveal_key(&swap_id, &seller, &secret, &blinding); + + // Swap is complete. + let swap = swap_client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Completed); + + // Registry ownership is still `seller`. + let registry_client = IpRegistryClient::new(&env, ®istry_id); + let ip_record = registry_client.get_ip(&ip_id); + assert_eq!( + ip_record.owner, seller, + "ip_registry ownership record must remain with the seller after swap completion \ + — the buyer must call ip_registry.transfer_ip to assume formal ownership" + ); + } + + // ── #833-4: Multiple swaps on same IP (sequential, each complete) ───────── + + /// After the first swap completes and the active-swap lock is released, + /// the same IP can be swapped again. Both swap records must reflect the + /// correct status independently. + #[test] + fn test_sequential_swaps_on_same_ip() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let token_admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let (registry_id, ip_id, secret, blinding) = setup_registry(&env, &seller); + let price: i128 = 100_000; + // Mint enough for two full purchases. + let token_id = setup_token(&env, &token_admin, &buyer, price * 3); + + let swap_addr = env.register(AtomicSwap, ()); + let swap_client = AtomicSwapClient::new(&env, &swap_addr); + swap_client.initialize(®istry_id, &treasury); + + // First swap. + let swap_id_1 = swap_client.initiate_swap( + &token_id, &ip_id, &seller, &price, &buyer, &0u32, &None, &0i128, &false, + ); + swap_client.accept_swap(&swap_id_1); + swap_client.reveal_key(&swap_id_1, &seller, &secret, &blinding); + let swap1 = swap_client.get_swap(&swap_id_1).unwrap(); + assert_eq!(swap1.status, SwapStatus::Completed); + + // Second swap on the same IP (lock released after first completion). + let swap_id_2 = swap_client.initiate_swap( + &token_id, &ip_id, &seller, &price, &buyer, &0u32, &None, &0i128, &false, + ); + swap_client.accept_swap(&swap_id_2); + swap_client.reveal_key(&swap_id_2, &seller, &secret, &blinding); + let swap2 = swap_client.get_swap(&swap_id_2).unwrap(); + assert_eq!(swap2.status, SwapStatus::Completed); + + // Both swap IDs must be distinct. + assert_ne!(swap_id_1, swap_id_2); + } + // ── helpers ─────────────────────────────────────────────────────────────── fn make_attribution(env: &Env, contract: &Address, reason: &str) -> FailureAttribution { diff --git a/contracts/atomic_swap/src/oracle_tests.rs b/contracts/atomic_swap/src/oracle_tests.rs index c9304a7..453bebb 100644 --- a/contracts/atomic_swap/src/oracle_tests.rs +++ b/contracts/atomic_swap/src/oracle_tests.rs @@ -141,7 +141,8 @@ mod oracle_tests { ) -> (AtomicSwapClient<'static>, Address) { let contract_id = env.register(AtomicSwap, ()); let client = AtomicSwapClient::new(env, &contract_id); - client.initialize(registry_id); + let treasury = Address::generate(env); + client.initialize(registry_id, &treasury); // Seed admin: first initiate_swap sets admin = seller client.initiate_swap( token_id, &ip_id, seller, &500_i128, buyer, &0_u32, &None, &0_i128, &false, @@ -267,7 +268,8 @@ mod oracle_tests { let registry_id = env.register(IpRegistry, ()); let contract_id = env.register(AtomicSwap, ()); let client = AtomicSwapClient::new(&env, &contract_id); - client.initialize(®istry_id); + let treasury = Address::generate(&env); + client.initialize(®istry_id, &treasury); assert!(client.get_oracle_config().is_none()); } @@ -308,7 +310,8 @@ mod oracle_tests { let registry_id = env.register(IpRegistry, ()); let contract_id = env.register(AtomicSwap, ()); let client = AtomicSwapClient::new(&env, &contract_id); - client.initialize(®istry_id); + let treasury = Address::generate(&env); + client.initialize(®istry_id, &treasury); let token = Address::generate(&env); let result = client.try_get_oracle_price(&token); @@ -703,7 +706,8 @@ mod oracle_tests { let token_id = setup_token(&env, &admin, &buyer, 10_000_000); let contract_id = env.register(AtomicSwap, ()); let client = AtomicSwapClient::new(&env, &contract_id); - client.initialize(®istry_id); + let treasury = Address::generate(&env); + client.initialize(®istry_id, &treasury); let result = client.try_initiate_swap_with_oracle_price( &token_id, &ip_id, &seller, &buyer, &0_u32, &None, &0_i128, &false, &0_i128, &0_i128, @@ -982,6 +986,220 @@ mod oracle_tests { assert!(swap.price >= 100_000_i128 && swap.price <= 500_000_i128); } + // ── #834: Boundary staleness tests ─────────────────────────────────────── + + /// Exactly at the threshold (staleness_secs == ORACLE_STALENESS_THRESHOLD_SECS == 300) + /// is still considered fresh (`<=` comparison), so a new attestation is + /// fetched and returned instead of the cached price. + #[test] + fn test_oracle_price_staleness_exactly_at_threshold_is_fresh() { + let env = Env::default(); + env.mock_all_auths(); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let admin = Address::generate(&env); + let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); + let token_id = setup_token(&env, &admin, &buyer, 10_000_000); + let oracle_id = env.register(MockOracle, ()); + let oracle_client = MockOracleClient::new(&env, &oracle_id); + let (client, admin_addr) = + setup_swap_contract(&env, ®istry_id, &token_id, ip_id, &seller, &buyer); + enable_oracle( + &env, + &client, + &admin_addr, + &oracle_id, + &oracle_client, + &token_id, + 500_000_i128, + ); + + // Establish cache with the initial price. + let initial_price = client.get_oracle_price(&token_id); + assert_eq!(initial_price, 500_000_i128); + + // Advance time by exactly the threshold (300 s). The staleness check is + // `staleness_secs <= ORACLE_STALENESS_THRESHOLD_SECS`, so 300 <= 300 is + // true — the path takes the *fresh* branch, fetches a new attestation, + // and returns the new price rather than the cache. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 300); + + // Publish a new signed price at the updated timestamp. + publish_price(&env, &oracle_client, &token_id, 550_000_i128); + + let price = client.get_oracle_price(&token_id); + // Exactly-at-threshold → fresh fetch path → new price returned. + assert_eq!( + price, 550_000_i128, + "price at exactly 300 s should use the fresh fetch path, not the cached price" + ); + + // Verify the cache was updated to the fresh price. + let config = client.get_oracle_config().unwrap(); + assert_eq!(config.cached_price, 550_000_i128); + } + + /// One second past the threshold (staleness_secs == 301) triggers the stale + /// branch: the cached (previously-verified) price is returned and the newly + /// published price is NOT fetched. + #[test] + fn test_oracle_price_staleness_just_past_threshold_uses_cache() { + let env = Env::default(); + env.mock_all_auths(); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let admin = Address::generate(&env); + let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); + let token_id = setup_token(&env, &admin, &buyer, 10_000_000); + let oracle_id = env.register(MockOracle, ()); + let oracle_client = MockOracleClient::new(&env, &oracle_id); + let (client, admin_addr) = + setup_swap_contract(&env, ®istry_id, &token_id, ip_id, &seller, &buyer); + enable_oracle( + &env, + &client, + &admin_addr, + &oracle_id, + &oracle_client, + &token_id, + 500_000_i128, + ); + + // Establish the cached price. + let initial_price = client.get_oracle_price(&token_id); + assert_eq!(initial_price, 500_000_i128); + + // Advance time by threshold + 1 (301 s). Now + // `staleness_secs (301) <= 300` is false → stale branch. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 301); + + // Publish a new signed price; it should NOT be used. + publish_price(&env, &oracle_client, &token_id, 700_000_i128); + + let price = client.get_oracle_price(&token_id); + // Just-past-threshold → stale branch → cached price returned. + assert_eq!( + price, 500_000_i128, + "price at 301 s should use the stale-fallback path and return the cached price" + ); + + // The cache must NOT have been updated (stale path does not write back). + let config = client.get_oracle_config().unwrap(); + assert_eq!(config.cached_price, 500_000_i128); + } + + #[test] + fn test_oracle_price_staleness_exactly_at_threshold_swap_uses_fresh_price() { + let env = Env::default(); + env.mock_all_auths(); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let admin = Address::generate(&env); + let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); + let token_id = setup_token(&env, &admin, &buyer, 10_000_000); + let oracle_id = env.register(MockOracle, ()); + let oracle_client = MockOracleClient::new(&env, &oracle_id); + let (client, admin_addr) = + setup_swap_contract(&env, ®istry_id, &token_id, ip_id, &seller, &buyer); + enable_oracle( + &env, + &client, + &admin_addr, + &oracle_id, + &oracle_client, + &token_id, + 500_000_i128, + ); + + // Establish cache. + let initial_price = client.get_oracle_price(&token_id); + assert_eq!(initial_price, 500_000_i128); + + // Advance exactly to the threshold. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 300); + + // Publish new price; exactly-at-threshold → fresh path → new price used. + publish_price(&env, &oracle_client, &token_id, 450_000_i128); + + // setup_swap_contract already cancelled the seeding swap (id 0), + // so the IP is free. Initiate a new swap now. + let swap_id = client.initiate_swap_with_oracle_price( + &token_id, + &ip_id, + &seller, + &buyer, + &0_u32, + &None, + &0_i128, + &false, + &0_i128, + &0_i128, + ); + let swap = client.get_swap(&swap_id).unwrap(); + assert_eq!( + swap.price, 450_000_i128, + "swap at exactly-at-threshold should use the fresh oracle price" + ); + } + + #[test] + fn test_oracle_price_staleness_just_past_threshold_swap_uses_cache() { + let env = Env::default(); + env.mock_all_auths(); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let admin = Address::generate(&env); + let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); + let token_id = setup_token(&env, &admin, &buyer, 10_000_000); + let oracle_id = env.register(MockOracle, ()); + let oracle_client = MockOracleClient::new(&env, &oracle_id); + let (client, admin_addr) = + setup_swap_contract(&env, ®istry_id, &token_id, ip_id, &seller, &buyer); + enable_oracle( + &env, + &client, + &admin_addr, + &oracle_id, + &oracle_client, + &token_id, + 500_000_i128, + ); + + // Establish cache. + let initial_price = client.get_oracle_price(&token_id); + assert_eq!(initial_price, 500_000_i128); + + // Advance 1 s past the threshold. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 301); + + // Publish a new price; stale path should ignore it and use the cache. + publish_price(&env, &oracle_client, &token_id, 700_000_i128); + + // setup_swap_contract already cancelled the seeding swap (id 0), + // so the IP is free. Initiate a new swap now. + let swap_id = client.initiate_swap_with_oracle_price( + &token_id, + &ip_id, + &seller, + &buyer, + &0_u32, + &None, + &0_i128, + &false, + &0_i128, + &0_i128, + ); + let swap = client.get_swap(&swap_id).unwrap(); + assert_eq!( + swap.price, 500_000_i128, + "swap at just-past-threshold should use the cached price, not the new oracle price" + ); + } + #[test] fn test_oracle_config_disable_preserves_cache() { let env = Env::default(); diff --git a/contracts/atomic_swap/src/upgrade.rs b/contracts/atomic_swap/src/upgrade.rs index 00a438b..411f585 100644 --- a/contracts/atomic_swap/src/upgrade.rs +++ b/contracts/atomic_swap/src/upgrade.rs @@ -370,237 +370,280 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { // does NOT invoke `env.deployer().update_current_contract_wasm`. This avoids // the need for a real Soroban deployer in unit tests. -// #[cfg(test)] -// mod tests { -// use super::*; -// // use soroban_sdk::Env; -// // -// // // ── helpers ─────────────────────────────────────────────────────────────── -// // -// // fn bump_version(s: &ContractSchema) -> ContractSchema { -// ContractSchema { -// version: s.version + 1, -// functions: s.functions.clone(), -// errors: s.errors.clone(), -// storage_keys: s.storage_keys.clone(), -// } -// } -// -// // ── 1. Valid upgrade passes ─────────────────────────────────────────────── -// -// /// Identical schema with bumped version must pass all checks. -// #[test] -// fn test_valid_upgrade_passes() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let v2 = bump_version(&v1); -// assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); -// } -// -// /// Adding a new function is an additive (non-breaking) change. -// #[test] -// fn test_additive_function_passes() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// v2.functions.push_back(FunctionEntry { -// name: String::from_str(&env, "new_query"), -// signature: String::from_str(&env, "new_query(id:u64)->bool"), -// }); -// assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); -// } -// -// /// Adding a new error code is an additive (non-breaking) change. -// #[test] -// fn test_additive_error_code_passes() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// v2.errors.push_back(ErrorEntry { -// name: String::from_str(&env, "NewError"), -// code: 99, -// }); -// assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); -// } -// -// /// Adding a new storage key is an additive (non-breaking) change. -// #[test] -// fn test_additive_storage_key_passes() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// v2.storage_keys.push_back(String::from_str(&env, "NewIndex")); -// assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); -// } -// -// // ── 2. Version gate ─────────────────────────────────────────────────────── -// -// /// Same version must be rejected. -// #[test] -// fn test_same_version_rejected() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// assert_eq!( -// check_schema_compatibility(&v1, &v1.clone()), -// Err(ContractError::SchemaNotGreater) -// ); -// } -// -// /// Lower version must be rejected. -// #[test] -// fn test_lower_version_rejected() { -// let env = Env::default(); -// let mut v1 = build_v1_schema(&env); -// v1.version = 5; -// let mut bad = v1.clone(); -// bad.version = 3; -// assert_eq!( -// check_schema_compatibility(&v1, &bad), -// Err(ContractError::SchemaNotGreater) -// ); -// } -// -// // ── 3. Missing function fails ───────────────────────────────────────────── -// -// /// Removing a function must be rejected. -// #[test] -// fn test_missing_function_fails() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// -// // Drop "cancel_swap" from v2. -// let mut trimmed: Vec = Vec::new(&env); -// for i in 0..v2.functions.len() { -// let f = v2.functions.get(i).unwrap(); -// if f.name != String::from_str(&env, "cancel_swap") { -// trimmed.push_back(f); -// } -// } -// v2.functions = trimmed; -// -// assert_eq!( -// check_schema_compatibility(&v1, &v2), -// Err(ContractError::MissingFunc) -// ); -// } -// -// /// Changing a function's signature must be rejected. -// #[test] -// fn test_function_signature_change_fails() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// -// let mut patched: Vec = Vec::new(&env); -// for i in 0..v2.functions.len() { -// let mut f = v2.functions.get(i).unwrap(); -// if f.name == String::from_str(&env, "get_swap") { -// // Change return type — breaking change. -// f.signature = String::from_str(&env, "get_swap(swap_id:u64)->SwapRecord"); -// } -// patched.push_back(f); -// } -// v2.functions = patched; -// -// // Schema compatibility check should fail for signature changes -// let result = check_schema_compatibility(&v1, &v2); -// assert!(result.is_err()); -// } -// -// // ── 4. Storage key mismatch fails ───────────────────────────────────────── -// -// /// Removing a storage key must be rejected. -// #[test] -// fn test_missing_storage_key_fails() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// -// // Drop "SwapHistory". -// let mut trimmed: Vec = Vec::new(&env); -// for i in 0..v2.storage_keys.len() { -// let k = v2.storage_keys.get(i).unwrap(); -// if k != String::from_str(&env, "SwapHistory") { -// trimmed.push_back(k); -// } -// } -// v2.storage_keys = trimmed; -// -// assert_eq!( -// check_schema_compatibility(&v1, &v2), -// Err(ContractError::MissingFunc) -// ); -// } -// -// // ── 5. Error code change fails ──────────────────────────────────────────── -// -// /// Removing an error entry must be rejected. -// #[test] -// fn test_missing_error_code_fails() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// -// // Drop "InvalidKey". -// let mut trimmed: Vec = Vec::new(&env); -// for i in 0..v2.errors.len() { -// let e = v2.errors.get(i).unwrap(); -// if e.name != String::from_str(&env, "InvalidKey") { -// trimmed.push_back(e); -// } -// } -// v2.errors = trimmed; -// -// assert_eq!( -// check_schema_compatibility(&v1, &v2), -// Err(ContractError::MissingFunc) -// ); -// } -// -// /// Renumbering an error code must be rejected. -// #[test] -// fn test_error_code_renumbered_fails() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// let mut v2 = bump_version(&v1); -// -// // Change "SwapNotFound" from 1 → 99. -// let mut patched: Vec = Vec::new(&env); -// for i in 0..v2.errors.len() { -// let mut e = v2.errors.get(i).unwrap(); -// if e.name == String::from_str(&env, "SwapNotFound") { -// e.code = 99; -// } -// patched.push_back(e); -// } -// v2.errors = patched; -// -// assert_eq!( -// check_schema_compatibility(&v1, &v2), -// Err(ContractError::FuncChanged) -// ); -// } -// -// // ── 6. Schema persistence ───────────────────────────────────────────────── -// -// /// `store_schema` / `load_schema` round-trip. -// #[test] -// fn test_store_and_load_schema_round_trip() { -// let env = Env::default(); -// let v1 = build_v1_schema(&env); -// store_schema(&env, &v1); -// let loaded = load_schema(&env).expect("schema must be present after store"); -// assert_eq!(loaded.version, v1.version); -// assert_eq!(loaded.functions.len(), v1.functions.len()); -// assert_eq!(loaded.errors.len(), v1.errors.len()); -// assert_eq!(loaded.storage_keys.len(), v1.storage_keys.len()); -// } -// -// /// `load_schema` returns `None` when nothing has been stored. +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + // ── helpers ─────────────────────────────────────────────────────────────── + + /// Return a copy of `s` with the version bumped by one. + fn bump_version(s: &ContractSchema) -> ContractSchema { + ContractSchema { + version: s.version + 1, + functions: s.functions.clone(), + errors: s.errors.clone(), + storage_keys: s.storage_keys.clone(), + } + } + + // ── 1. Valid upgrade passes ─────────────────────────────────────────────── + + /// Identical schema with bumped version must pass all checks. + #[test] + fn test_valid_upgrade_passes() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let v2 = bump_version(&v1); + assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); + } + + /// Adding a new function is an additive (non-breaking) change. + #[test] + fn test_additive_function_passes() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + v2.functions.push_back(FunctionEntry { + name: String::from_str(&env, "new_query"), + signature: String::from_str(&env, "new_query(id:u64)->bool"), + }); + assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); + } + + /// Adding a new error code is an additive (non-breaking) change. + #[test] + fn test_additive_error_code_passes() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + v2.errors.push_back(ErrorEntry { + name: String::from_str(&env, "NewError"), + code: 99, + }); + assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); + } + + /// Adding a new storage key is an additive (non-breaking) change. + #[test] + fn test_additive_storage_key_passes() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + v2.storage_keys.push_back(String::from_str(&env, "NewIndex")); + assert_eq!(check_schema_compatibility(&v1, &v2), Ok(())); + } + + // ── 2. Version gate ─────────────────────────────────────────────────────── + + /// Same version must be rejected. + #[test] + fn test_same_version_rejected() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + assert_eq!( + check_schema_compatibility(&v1, &v1.clone()), + Err(ContractError::SchemaNotGreater) + ); + } + + /// Lower version must be rejected. + #[test] + fn test_lower_version_rejected() { + let env = Env::default(); + let mut v1 = build_v1_schema(&env); + v1.version = 5; + let mut bad = v1.clone(); + bad.version = 3; + assert_eq!( + check_schema_compatibility(&v1, &bad), + Err(ContractError::SchemaNotGreater) + ); + } + + // ── 3. Missing function fails ───────────────────────────────────────────── + + /// Removing a function must be rejected. + #[test] + fn test_missing_function_fails() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + + // Drop "cancel_swap" from v2. + let mut trimmed: Vec = Vec::new(&env); + for i in 0..v2.functions.len() { + let f = v2.functions.get(i).unwrap(); + if f.name != String::from_str(&env, "cancel_swap") { + trimmed.push_back(f); + } + } + v2.functions = trimmed; + + assert_eq!( + check_schema_compatibility(&v1, &v2), + Err(ContractError::MissingFunc) + ); + } + + /// Changing a function's signature must be rejected. + #[test] + fn test_function_signature_change_fails() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + + let mut patched: Vec = Vec::new(&env); + for i in 0..v2.functions.len() { + let mut f = v2.functions.get(i).unwrap(); + if f.name == String::from_str(&env, "get_swap") { + // Change return type — breaking change. + f.signature = String::from_str(&env, "get_swap(swap_id:u64)->SwapRecord"); + } + patched.push_back(f); + } + v2.functions = patched; + + // Signature changed → compatibility check must fail. + let result = check_schema_compatibility(&v1, &v2); + assert_eq!(result, Err(ContractError::FuncChanged)); + } + + // ── 4. Storage key mismatch fails ───────────────────────────────────────── + + /// Removing a storage key must be rejected. + #[test] + fn test_missing_storage_key_fails() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + + // Drop "SwapHistory". + let mut trimmed: Vec = Vec::new(&env); + for i in 0..v2.storage_keys.len() { + let k = v2.storage_keys.get(i).unwrap(); + if k != String::from_str(&env, "SwapHistory") { + trimmed.push_back(k); + } + } + v2.storage_keys = trimmed; + + assert_eq!( + check_schema_compatibility(&v1, &v2), + Err(ContractError::MissingFunc) + ); + } + + // ── 5. Error code change fails ──────────────────────────────────────────── + + /// Removing an error entry must be rejected. + #[test] + fn test_missing_error_code_fails() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + + // Drop "InvalidKey". + let mut trimmed: Vec = Vec::new(&env); + for i in 0..v2.errors.len() { + let e = v2.errors.get(i).unwrap(); + if e.name != String::from_str(&env, "InvalidKey") { + trimmed.push_back(e); + } + } + v2.errors = trimmed; + + assert_eq!( + check_schema_compatibility(&v1, &v2), + Err(ContractError::MissingFunc) + ); + } + + /// Renumbering an error code must be rejected. + #[test] + fn test_error_code_renumbered_fails() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + + // Change "SwapNotFound" from 1 → 99. + let mut patched: Vec = Vec::new(&env); + for i in 0..v2.errors.len() { + let mut e = v2.errors.get(i).unwrap(); + if e.name == String::from_str(&env, "SwapNotFound") { + e.code = 99; + } + patched.push_back(e); + } + v2.errors = patched; + + assert_eq!( + check_schema_compatibility(&v1, &v2), + Err(ContractError::FuncChanged) + ); + } + + // ── 6. Schema persistence ───────────────────────────────────────────────── + + /// `store_schema` / `load_schema` round-trip. + #[test] + fn test_store_and_load_schema_round_trip() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + store_schema(&env, &v1); + let loaded = load_schema(&env).expect("schema must be present after store"); + assert_eq!(loaded.version, v1.version); + assert_eq!(loaded.functions.len(), v1.functions.len()); + assert_eq!(loaded.errors.len(), v1.errors.len()); + assert_eq!(loaded.storage_keys.len(), v1.storage_keys.len()); + } + + /// `load_schema` returns `None` when nothing has been stored. #[test] fn test_load_schema_returns_none_when_absent() { let env = Env::default(); assert!(load_schema(&env).is_none()); } -// } + + // ── 7. Wasm-hash zero rejected by validate_upgrade guard ───────────────── + + /// `validate_upgrade` must reject the zero WASM hash regardless of schema + /// validity. This guard is enforced before any schema logic runs. + /// + /// Because `env.deployer().update_current_contract_wasm` cannot be called + /// in unit tests, we test only the pure `check_schema_compatibility` layer + /// here. The zero-hash guard in `validate_upgrade` is covered by the + /// integration tests in lib.rs / upgrade_chaos_tests.rs. + #[test] + fn test_check_schema_rejects_same_version() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + // A candidate schema with the same version as the stored one must fail. + let same = v1.clone(); + assert_eq!( + check_schema_compatibility(&v1, &same), + Err(ContractError::SchemaNotGreater), + "same version must be rejected by check_schema_compatibility" + ); + } + + /// A fully valid v2 schema (v1 + bumped version + one new function) passes. + #[test] + fn test_valid_v2_schema_with_new_function_passes() { + let env = Env::default(); + let v1 = build_v1_schema(&env); + let mut v2 = bump_version(&v1); + v2.functions.push_back(FunctionEntry { + name: String::from_str(&env, "batch_reveal_keys"), + signature: String::from_str( + &env, + "batch_reveal_keys(swap_ids:Vec,caller:Address,secrets:Vec>,blindings:Vec>)->()", + ), + }); + assert_eq!( + check_schema_compatibility(&v1, &v2), + Ok(()), + "v2 with an additive function must be accepted" + ); + } +}