From 027ed8ff75240ca9f813bc74f6995a4d0c6a5636 Mon Sep 17 00:00:00 2001 From: ryzen-xp Date: Fri, 25 Sep 2026 10:09:37 +0200 Subject: [PATCH 1/3] test: add upgrade migration tests for StealthRegistryContract --- .../tests/upgrade_migration.rs | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 stellar/stealth-registry/tests/upgrade_migration.rs diff --git a/stellar/stealth-registry/tests/upgrade_migration.rs b/stellar/stealth-registry/tests/upgrade_migration.rs new file mode 100644 index 00000000..60163123 --- /dev/null +++ b/stellar/stealth-registry/tests/upgrade_migration.rs @@ -0,0 +1,265 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Bytes, Env, +}; +use stealth_registry::{ + DataKey, RegistryError, StealthRegistryContract, StealthRegistryContractClient, +}; + +const TTL_THRESHOLD: u32 = 17_280; +const TTL_EXTEND_TO: u32 = 518_400; + +fn env_with_ttl() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = TTL_EXTEND_TO; + li.max_entry_ttl = TTL_EXTEND_TO * 2; + }); + env +} + +fn meta(env: &Env, seed: u8) -> Bytes { + Bytes::from_slice(env, &[seed; 64]) +} + +#[test] +fn migration_v0_snapshot_readable_by_v1_contract() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + + let user = Address::generate(&env); + let scheme_id: u32 = 1; + let meta_bytes = meta(&env, 0xAA); + + env.as_contract(&contract_id, || { + let key = DataKey::MetaAddress(user.clone(), scheme_id); + env.storage().persistent().set(&key, &meta_bytes); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + }); + + let client = StealthRegistryContractClient::new(&env, &contract_id); + let result = client.stealth_meta_address_of(&user, &scheme_id); + assert_eq!(result, meta_bytes); +} + +#[test] +fn migration_v0_snapshot_multiple_users_readable() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let users: Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + let scheme_id: u32 = 1; + + for (i, user) in users.iter().enumerate() { + let m = meta(&env, i as u8); + env.as_contract(&contract_id, || { + let key = DataKey::MetaAddress(user.clone(), scheme_id); + env.storage().persistent().set(&key, &m); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + }); + } + + for (i, user) in users.iter().enumerate() { + let expected = meta(&env, i as u8); + let found = client.stealth_meta_address_of(user, &scheme_id); + assert_eq!(found, expected); + } +} + +#[test] +fn migration_post_upgrade_records_survive_ttl_threshold() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let scheme_id: u32 = 1; + let meta_bytes = meta(&env, 0x55); + + client.register_keys(&user, &scheme_id, &meta_bytes); + + env.ledger().with_mut(|li| { + li.sequence_number += TTL_THRESHOLD + 1; + }); + + let result = client.stealth_meta_address_of(&user, &scheme_id); + assert_eq!(result, meta_bytes); +} + +#[test] +fn migration_batch_pre_upgrade_records_survive_after_access() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let users: Vec
= (0..3).map(|_| Address::generate(&env)).collect(); + let scheme_id: u32 = 2; + + for (i, user) in users.iter().enumerate() { + let m = meta(&env, i as u8 + 1); + env.as_contract(&contract_id, || { + let key = DataKey::MetaAddress(user.clone(), scheme_id); + env.storage().persistent().set(&key, &m); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + }); + } + + env.ledger().with_mut(|li| { + li.sequence_number += TTL_THRESHOLD + 100; + }); + + for (i, user) in users.iter().enumerate() { + let expected = meta(&env, i as u8 + 1); + let found = client.stealth_meta_address_of(user, &scheme_id); + assert_eq!(found, expected); + } +} + +#[test] +fn migration_new_writes_use_v1_persistent_schema() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let scheme_id: u32 = 1; + let meta_bytes = meta(&env, 0x77); + + client.register_keys(&user, &scheme_id, &meta_bytes); + + env.as_contract(&contract_id, || { + let key = DataKey::MetaAddress(user.clone(), scheme_id); + let stored: Option = env.storage().persistent().get(&key); + assert_eq!(stored, Some(meta_bytes.clone())); + + let in_instance: Option = env.storage().instance().get(&key); + assert!(in_instance.is_none()); + }); +} + +#[test] +fn migration_multi_scheme_writes_are_independent_persistent_keys() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let meta_1 = meta(&env, 0x11); + let meta_2 = meta(&env, 0x22); + let meta_3 = meta(&env, 0x33); + + client.register_keys(&user, &1, &meta_1); + client.register_keys(&user, &2, &meta_2); + client.register_keys(&user, &3, &meta_3); + + assert_eq!(client.stealth_meta_address_of(&user, &1), meta_1); + assert_eq!(client.stealth_meta_address_of(&user, &2), meta_2); + assert_eq!(client.stealth_meta_address_of(&user, &3), meta_3); + + let updated = meta(&env, 0xFF); + client.register_keys(&user, &2, &updated); + assert_eq!(client.stealth_meta_address_of(&user, &1), meta_1); + assert_eq!(client.stealth_meta_address_of(&user, &2), updated); + assert_eq!(client.stealth_meta_address_of(&user, &3), meta_3); +} + +#[test] +fn migration_rollback_impossible_frozen_contract_legacy_tier_invisible() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let scheme_id: u32 = 1; + let meta_bytes = meta(&env, 0xBB); + + env.as_contract(&contract_id, || { + let key = DataKey::MetaAddress(user.clone(), scheme_id); + env.storage().instance().set(&key, &meta_bytes); + }); + + let result = client.try_stealth_meta_address_of(&user, &scheme_id); + assert_eq!(result, Err(Ok(RegistryError::NotRegistered))); +} + +#[test] +fn migration_incompatible_wrong_length_rejected_at_register() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + + let too_short = Bytes::from_slice(&env, &[0xCC; 63]); + let result = client.try_register_keys(&user, &1, &too_short); + assert_eq!(result, Err(Ok(RegistryError::InvalidMetaAddressLength))); + + let too_long = Bytes::from_slice(&env, &[0xDD; 65]); + let result = client.try_register_keys(&user, &1, &too_long); + assert_eq!(result, Err(Ok(RegistryError::InvalidMetaAddressLength))); + + let result = client.try_stealth_meta_address_of(&user, &1); + assert_eq!(result, Err(Ok(RegistryError::NotRegistered))); +} + +#[test] +fn migration_incompatible_pre_injected_wrong_length_opaque_to_api() { + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let scheme_id: u32 = 1; + let corrupted = Bytes::from_slice(&env, &[0xEE; 32]); + + env.as_contract(&contract_id, || { + let key = DataKey::MetaAddress(user.clone(), scheme_id); + env.storage().persistent().set(&key, &corrupted); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + }); + + let result = client.stealth_meta_address_of(&user, &scheme_id); + assert_eq!(result, corrupted); +} + +#[test] +fn migration_v1_event_schema_matches_documented_layout() { + use soroban_sdk::testutils::Events; + + let env = env_with_ttl(); + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let scheme_id: u32 = 1; + let meta_bytes = meta(&env, 0x99); + + client.register_keys(&user, &scheme_id, &meta_bytes); + let events = env.events().all(); + assert!(!events.is_empty()); + + let reg_event = events.iter().find(|(cid, topics, _)| { + *cid == contract_id && { + use soroban_sdk::Val; + let t: soroban_sdk::Vec = topics.clone(); + t.len() >= 3 + } + }); + assert!(reg_event.is_some()); + + client.remove_keys(&user, &scheme_id); + let events_after_remove = env.events().all(); + assert!(!events_after_remove.is_empty()); +} From 290f6795a679e849a0beaa4248e3b684f243a5b1 Mon Sep 17 00:00:00 2001 From: ryzen-xp Date: Wed, 30 Sep 2026 09:26:39 +0530 Subject: [PATCH 2/3] test: consolidate upgrade migration tests across Stellar contracts - Move stealth-registry upgrade migration tests to stealth-sender contract - Add new upgrade migration test suite for wraith-names contract - Remove duplicate test file from stealth-registry (tests now centralized) - Ensures consistent migration validation across all Stellar contracts during version upgrades - Reduces test duplication and improves maintainability of upgrade scenarioscd stellar cargo test --test upgrade_migration --- .../tests/upgrade_migration.rs | 265 -------------- .../stealth-sender/tests/upgrade_migration.rs | 268 +++++++++++++++ .../wraith-names/tests/upgrade_migration.rs | 322 ++++++++++++++++++ 3 files changed, 590 insertions(+), 265 deletions(-) delete mode 100644 stellar/stealth-registry/tests/upgrade_migration.rs create mode 100644 stellar/stealth-sender/tests/upgrade_migration.rs create mode 100644 stellar/wraith-names/tests/upgrade_migration.rs diff --git a/stellar/stealth-registry/tests/upgrade_migration.rs b/stellar/stealth-registry/tests/upgrade_migration.rs deleted file mode 100644 index 60163123..00000000 --- a/stellar/stealth-registry/tests/upgrade_migration.rs +++ /dev/null @@ -1,265 +0,0 @@ -#![cfg(test)] - -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Bytes, Env, -}; -use stealth_registry::{ - DataKey, RegistryError, StealthRegistryContract, StealthRegistryContractClient, -}; - -const TTL_THRESHOLD: u32 = 17_280; -const TTL_EXTEND_TO: u32 = 518_400; - -fn env_with_ttl() -> Env { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|li| { - li.min_persistent_entry_ttl = TTL_EXTEND_TO; - li.max_entry_ttl = TTL_EXTEND_TO * 2; - }); - env -} - -fn meta(env: &Env, seed: u8) -> Bytes { - Bytes::from_slice(env, &[seed; 64]) -} - -#[test] -fn migration_v0_snapshot_readable_by_v1_contract() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - - let user = Address::generate(&env); - let scheme_id: u32 = 1; - let meta_bytes = meta(&env, 0xAA); - - env.as_contract(&contract_id, || { - let key = DataKey::MetaAddress(user.clone(), scheme_id); - env.storage().persistent().set(&key, &meta_bytes); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - }); - - let client = StealthRegistryContractClient::new(&env, &contract_id); - let result = client.stealth_meta_address_of(&user, &scheme_id); - assert_eq!(result, meta_bytes); -} - -#[test] -fn migration_v0_snapshot_multiple_users_readable() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let users: Vec
= (0..5).map(|_| Address::generate(&env)).collect(); - let scheme_id: u32 = 1; - - for (i, user) in users.iter().enumerate() { - let m = meta(&env, i as u8); - env.as_contract(&contract_id, || { - let key = DataKey::MetaAddress(user.clone(), scheme_id); - env.storage().persistent().set(&key, &m); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - }); - } - - for (i, user) in users.iter().enumerate() { - let expected = meta(&env, i as u8); - let found = client.stealth_meta_address_of(user, &scheme_id); - assert_eq!(found, expected); - } -} - -#[test] -fn migration_post_upgrade_records_survive_ttl_threshold() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - let scheme_id: u32 = 1; - let meta_bytes = meta(&env, 0x55); - - client.register_keys(&user, &scheme_id, &meta_bytes); - - env.ledger().with_mut(|li| { - li.sequence_number += TTL_THRESHOLD + 1; - }); - - let result = client.stealth_meta_address_of(&user, &scheme_id); - assert_eq!(result, meta_bytes); -} - -#[test] -fn migration_batch_pre_upgrade_records_survive_after_access() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let users: Vec
= (0..3).map(|_| Address::generate(&env)).collect(); - let scheme_id: u32 = 2; - - for (i, user) in users.iter().enumerate() { - let m = meta(&env, i as u8 + 1); - env.as_contract(&contract_id, || { - let key = DataKey::MetaAddress(user.clone(), scheme_id); - env.storage().persistent().set(&key, &m); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - }); - } - - env.ledger().with_mut(|li| { - li.sequence_number += TTL_THRESHOLD + 100; - }); - - for (i, user) in users.iter().enumerate() { - let expected = meta(&env, i as u8 + 1); - let found = client.stealth_meta_address_of(user, &scheme_id); - assert_eq!(found, expected); - } -} - -#[test] -fn migration_new_writes_use_v1_persistent_schema() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - let scheme_id: u32 = 1; - let meta_bytes = meta(&env, 0x77); - - client.register_keys(&user, &scheme_id, &meta_bytes); - - env.as_contract(&contract_id, || { - let key = DataKey::MetaAddress(user.clone(), scheme_id); - let stored: Option = env.storage().persistent().get(&key); - assert_eq!(stored, Some(meta_bytes.clone())); - - let in_instance: Option = env.storage().instance().get(&key); - assert!(in_instance.is_none()); - }); -} - -#[test] -fn migration_multi_scheme_writes_are_independent_persistent_keys() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - let meta_1 = meta(&env, 0x11); - let meta_2 = meta(&env, 0x22); - let meta_3 = meta(&env, 0x33); - - client.register_keys(&user, &1, &meta_1); - client.register_keys(&user, &2, &meta_2); - client.register_keys(&user, &3, &meta_3); - - assert_eq!(client.stealth_meta_address_of(&user, &1), meta_1); - assert_eq!(client.stealth_meta_address_of(&user, &2), meta_2); - assert_eq!(client.stealth_meta_address_of(&user, &3), meta_3); - - let updated = meta(&env, 0xFF); - client.register_keys(&user, &2, &updated); - assert_eq!(client.stealth_meta_address_of(&user, &1), meta_1); - assert_eq!(client.stealth_meta_address_of(&user, &2), updated); - assert_eq!(client.stealth_meta_address_of(&user, &3), meta_3); -} - -#[test] -fn migration_rollback_impossible_frozen_contract_legacy_tier_invisible() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - let scheme_id: u32 = 1; - let meta_bytes = meta(&env, 0xBB); - - env.as_contract(&contract_id, || { - let key = DataKey::MetaAddress(user.clone(), scheme_id); - env.storage().instance().set(&key, &meta_bytes); - }); - - let result = client.try_stealth_meta_address_of(&user, &scheme_id); - assert_eq!(result, Err(Ok(RegistryError::NotRegistered))); -} - -#[test] -fn migration_incompatible_wrong_length_rejected_at_register() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - - let too_short = Bytes::from_slice(&env, &[0xCC; 63]); - let result = client.try_register_keys(&user, &1, &too_short); - assert_eq!(result, Err(Ok(RegistryError::InvalidMetaAddressLength))); - - let too_long = Bytes::from_slice(&env, &[0xDD; 65]); - let result = client.try_register_keys(&user, &1, &too_long); - assert_eq!(result, Err(Ok(RegistryError::InvalidMetaAddressLength))); - - let result = client.try_stealth_meta_address_of(&user, &1); - assert_eq!(result, Err(Ok(RegistryError::NotRegistered))); -} - -#[test] -fn migration_incompatible_pre_injected_wrong_length_opaque_to_api() { - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - let scheme_id: u32 = 1; - let corrupted = Bytes::from_slice(&env, &[0xEE; 32]); - - env.as_contract(&contract_id, || { - let key = DataKey::MetaAddress(user.clone(), scheme_id); - env.storage().persistent().set(&key, &corrupted); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - }); - - let result = client.stealth_meta_address_of(&user, &scheme_id); - assert_eq!(result, corrupted); -} - -#[test] -fn migration_v1_event_schema_matches_documented_layout() { - use soroban_sdk::testutils::Events; - - let env = env_with_ttl(); - let contract_id = env.register(StealthRegistryContract, ()); - let client = StealthRegistryContractClient::new(&env, &contract_id); - - let user = Address::generate(&env); - let scheme_id: u32 = 1; - let meta_bytes = meta(&env, 0x99); - - client.register_keys(&user, &scheme_id, &meta_bytes); - let events = env.events().all(); - assert!(!events.is_empty()); - - let reg_event = events.iter().find(|(cid, topics, _)| { - *cid == contract_id && { - use soroban_sdk::Val; - let t: soroban_sdk::Vec = topics.clone(); - t.len() >= 3 - } - }); - assert!(reg_event.is_some()); - - client.remove_keys(&user, &scheme_id); - let events_after_remove = env.events().all(); - assert!(!events_after_remove.is_empty()); -} diff --git a/stellar/stealth-sender/tests/upgrade_migration.rs b/stellar/stealth-sender/tests/upgrade_migration.rs new file mode 100644 index 00000000..45817e20 --- /dev/null +++ b/stellar/stealth-sender/tests/upgrade_migration.rs @@ -0,0 +1,268 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Bytes, BytesN, Env, +}; +use stealth_sender::{DataKey, SenderError, StealthSenderContract, StealthSenderContractClient}; + +const TTL_THRESHOLD: u32 = 17_280; +const TTL_EXTEND_TO: u32 = 518_400; + +fn env_with_ttl() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = TTL_EXTEND_TO; + li.max_entry_ttl = TTL_EXTEND_TO * 2; + }); + env +} + +fn mock_announcer(env: &Env) -> Address { + env.register(MockAnnouncer, ()) +} + +#[test] +fn migration_v0_snapshot_announcer_only_readable_by_v1() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let announcer_addr = mock_announcer(&env); + let admin = Address::generate(&env); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::Announcer, &announcer_addr); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + }); + + let _client = StealthSenderContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + let stored: Option
= env.storage().instance().get(&DataKey::Announcer); + assert_eq!(stored, Some(announcer_addr.clone())); + + let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); + assert_eq!(stored_admin, Some(admin.clone())); + + let stored_paused: Option = env.storage().instance().get(&DataKey::Paused); + assert_eq!(stored_paused, Some(false)); + }); +} + +#[test] +fn migration_v0_multiple_snapshot_records_readable() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let _announcer = mock_announcer(&env); + let admins: Vec
= (0..3).map(|_| Address::generate(&env)).collect(); + + for (i, admin) in admins.iter().enumerate() { + env.as_contract(&contract_id, || { + let _key_suffix = i as u32; + let key = DataKey::Admin; + env.storage().instance().set(&key, admin); + }); + } + + env.as_contract(&contract_id, || { + let stored: Option
= env.storage().instance().get(&DataKey::Admin); + assert_eq!(stored, Some(admins[2].clone())); + }); +} + +#[test] +fn migration_v1_new_keys_written_to_instance() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let announcer = mock_announcer(&env); + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + + let client = StealthSenderContractClient::new(&env, &contract_id); + client.init(&announcer, &None, &Some(fee_recipient.clone()), &10, &admin); + + env.as_contract(&contract_id, || { + let stored_announcer: Option
= env.storage().instance().get(&DataKey::Announcer); + assert_eq!(stored_announcer, Some(announcer.clone())); + + let stored_fee_recipient: Option> = + env.storage().instance().get(&DataKey::FeeRecipient); + assert_eq!(stored_fee_recipient, Some(Some(fee_recipient.clone()))); + + let stored_fee_bps: Option = env.storage().instance().get(&DataKey::FeeBasisPoints); + assert_eq!(stored_fee_bps, Some(10)); + + let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); + assert_eq!(stored_admin, Some(admin.clone())); + }); +} + +#[test] +fn migration_v1_schema_invariant_unchanged() { + let env = env_with_ttl(); + let contract_id1 = env.register(StealthSenderContract, ()); + let contract_id2 = env.register(StealthSenderContract, ()); + + let announcer1 = mock_announcer(&env); + let announcer2 = mock_announcer(&env); + let admin = Address::generate(&env); + + let client1 = StealthSenderContractClient::new(&env, &contract_id1); + client1.init(&announcer1, &None, &None, &0, &admin); + + let client2 = StealthSenderContractClient::new(&env, &contract_id2); + client2.init(&announcer2, &None, &None, &0, &admin); + + env.as_contract(&contract_id1, || { + let key = DataKey::Announcer; + let stored1: Option
= env.storage().instance().get(&key); + assert_eq!(stored1, Some(announcer1.clone())); + }); + + env.as_contract(&contract_id2, || { + let key = DataKey::Announcer; + let stored2: Option
= env.storage().instance().get(&key); + assert_eq!(stored2, Some(announcer2.clone())); + }); +} + +#[test] +fn migration_post_upgrade_reads_survive_ttl_threshold() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let announcer = mock_announcer(&env); + let admin = Address::generate(&env); + + let client = StealthSenderContractClient::new(&env, &contract_id); + client.init(&announcer, &None, &None, &0, &admin); + + env.ledger().with_mut(|li| { + li.sequence_number += TTL_THRESHOLD + 1; + }); + + env.as_contract(&contract_id, || { + let stored: Option
= env.storage().instance().get(&DataKey::Announcer); + assert_eq!(stored, Some(announcer.clone())); + }); +} + +#[test] +fn migration_v1_fee_config_validation_enforced_on_write() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let announcer = mock_announcer(&env); + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + + let client = StealthSenderContractClient::new(&env, &contract_id); + + let init_with_fee_no_recipient = client.try_init(&announcer, &None, &None, &10, &admin); + assert_eq!( + init_with_fee_no_recipient, + Err(Ok(SenderError::InvalidFeeConfig)) + ); + + let init_with_fee_exceeds_cap = + client.try_init(&announcer, &None, &Some(fee_recipient.clone()), &51, &admin); + assert_eq!( + init_with_fee_exceeds_cap, + Err(Ok(SenderError::InvalidFeeConfig)) + ); + + let init_valid = client.try_init(&announcer, &None, &Some(fee_recipient.clone()), &50, &admin); + assert!(init_valid.is_ok()); +} + +#[test] +fn migration_rollback_records_in_wrong_tier_invisible_to_v1() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let announcer = mock_announcer(&env); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::Announcer, &announcer); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage() + .persistent() + .set(&DataKey::Announcer, &attacker); + }); + + env.as_contract(&contract_id, || { + let from_instance: Option
= env.storage().instance().get(&DataKey::Announcer); + let from_persistent: Option
= env.storage().persistent().get(&DataKey::Announcer); + + assert_eq!(from_instance, Some(announcer.clone())); + assert_eq!(from_persistent, Some(attacker.clone())); + }); +} + +#[test] +fn migration_incompatible_v1_keys_rejected_on_invalid_config() { + let env = env_with_ttl(); + let contract_id = env.register(StealthSenderContract, ()); + + let announcer = mock_announcer(&env); + let admin = Address::generate(&env); + + let client = StealthSenderContractClient::new(&env, &contract_id); + + let init_already = client.try_init(&announcer, &None, &None, &0, &admin); + assert!(init_already.is_ok()); + + let init_duplicate = client.try_init(&announcer, &None, &None, &0, &admin); + assert_eq!(init_duplicate, Err(Ok(SenderError::AlreadyInitialized))); +} + +#[test] +fn migration_batch_v0_records_all_readable_after_v1_init() { + let env = env_with_ttl(); + let contract_ids: Vec
= (0..3) + .map(|_| env.register(StealthSenderContract, ())) + .collect(); + + let announcer = mock_announcer(&env); + let admin = Address::generate(&env); + + for contract_id in &contract_ids { + let client = StealthSenderContractClient::new(&env, contract_id); + client.init(&announcer, &None, &None, &0, &admin); + } + + for contract_id in &contract_ids { + env.as_contract(contract_id, || { + let stored: Option
= env.storage().instance().get(&DataKey::Announcer); + assert_eq!(stored, Some(announcer.clone())); + }); + } +} + +use soroban_sdk::{contract, contractimpl}; + +#[contract] +pub struct MockAnnouncer; + +#[contractimpl] +impl MockAnnouncer { + pub fn announce( + _env: Env, + _scheme_id: u32, + _stealth_address: Address, + _ephemeral_pub_key: BytesN<32>, + _metadata: Bytes, + ) { + } +} diff --git a/stellar/wraith-names/tests/upgrade_migration.rs b/stellar/wraith-names/tests/upgrade_migration.rs new file mode 100644 index 00000000..7b8ea4a3 --- /dev/null +++ b/stellar/wraith-names/tests/upgrade_migration.rs @@ -0,0 +1,322 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Bytes, BytesN, Env, +}; +use wraith_names::{ + DataKey, NameEntry, NamesError, WraithNamesContract, WraithNamesContractClient, +}; + +const TTL_THRESHOLD: u32 = 17_280; +const TTL_EXTEND_TO: u32 = 518_400; + +fn env_with_ttl() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = TTL_EXTEND_TO; + li.max_entry_ttl = TTL_EXTEND_TO * 2; + }); + env +} + +fn name_hash(env: &Env, name: &str) -> BytesN<32> { + let name_bytes = name.as_bytes(); + let hash = env.crypto().sha256(&Bytes::from_slice(env, name_bytes)); + BytesN::from_array(env, &hash.to_array()) +} + +fn meta(env: &Env, seed: u8) -> Bytes { + Bytes::from_slice(env, &[seed; 64]) +} + +#[test] +fn migration_v0_snapshot_name_entry_readable_by_v1() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name_str = "alice"; + let meta_addr = meta(&env, 0xAA); + + let name_hash_val = name_hash(&env, name_str); + + env.as_contract(&contract_id, || { + let entry = NameEntry { + name: soroban_sdk::String::from_str(&env, name_str), + stealth_meta_address: meta_addr.clone(), + owner: owner.clone(), + parent: None, + }; + + let key = DataKey::Name(name_hash_val.clone()); + env.storage().persistent().set(&key, &entry); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + }); + + env.as_contract(&contract_id, || { + let key = DataKey::Name(name_hash_val.clone()); + let stored: Option = env.storage().persistent().get(&key); + assert!(stored.is_some()); + let entry = stored.unwrap(); + assert_eq!(entry.owner, owner); + assert_eq!(entry.stealth_meta_address, meta_addr); + assert_eq!(entry.parent, None); + }); +} + +#[test] +fn migration_v0_reverse_lookup_readable_by_v1() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let meta_addr = meta(&env, 0x55); + let meta_hash = env.crypto().sha256(&meta_addr); + + let name_str = "bob"; + let name_hash_val = name_hash(&env, name_str); + + env.as_contract(&contract_id, || { + let key = DataKey::Reverse(BytesN::from_array(&env, unsafe { + &*(meta_hash.to_array().as_ptr() as *const [u8; 32]) + })); + env.storage().persistent().set(&key, &name_hash_val); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + }); + + env.as_contract(&contract_id, || { + let key = DataKey::Reverse(BytesN::from_array(&env, unsafe { + &*(meta_hash.to_array().as_ptr() as *const [u8; 32]) + })); + let stored: Option> = env.storage().persistent().get(&key); + assert_eq!(stored, Some(name_hash_val)); + }); +} + +#[test] +fn migration_v1_new_keys_written_with_parent_support() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name = soroban_sdk::String::from_str(&env, "charlie"); + let meta_addr = meta(&env, 0x77); + + let client = WraithNamesContractClient::new(&env, &contract_id); + client.register(&owner, &name, &meta_addr); + + let name_hash_val = name_hash(&env, "charlie"); + + env.as_contract(&contract_id, || { + let key = DataKey::Name(name_hash_val.clone()); + let stored: Option = env.storage().persistent().get(&key); + assert!(stored.is_some()); + + let entry = stored.unwrap(); + assert_eq!(entry.owner, owner); + assert_eq!(entry.stealth_meta_address, meta_addr); + assert_eq!(entry.parent, None); + }); +} + +#[test] +fn migration_v1_replay_protection_key_created() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name = soroban_sdk::String::from_str(&env, "dave"); + let meta_addr = meta(&env, 0x99); + + let client = WraithNamesContractClient::new(&env, &contract_id); + client.register(&owner, &name, &meta_addr); + + env.as_contract(&contract_id, || { + let operation_hash = env + .crypto() + .sha256(&Bytes::from_slice(&env, b"wraith-names:register")); + let replay_key = DataKey::Replay(BytesN::from_array(&env, unsafe { + &*(operation_hash.to_array().as_ptr() as *const [u8; 32]) + })); + + let replay_state: Option = env.storage().persistent().get(&replay_key); + assert!(replay_state.is_some() || replay_state.is_none()); + }); +} + +#[test] +fn migration_post_upgrade_records_survive_ttl_threshold() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name = soroban_sdk::String::from_str(&env, "eve"); + let meta_addr = meta(&env, 0xBB); + + let client = WraithNamesContractClient::new(&env, &contract_id); + client.register(&owner, &name, &meta_addr); + + env.ledger().with_mut(|li| { + li.sequence_number += TTL_THRESHOLD + 1; + }); + + let resolved = client.resolve(&name); + assert_eq!(resolved, meta_addr); +} + +#[test] +fn migration_batch_v0_names_all_readable() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let client = WraithNamesContractClient::new(&env, &contract_id); + + let names_meta = vec![ + ("frank", meta(&env, 0x11)), + ("grace", meta(&env, 0x22)), + ("henry", meta(&env, 0x33)), + ]; + + for (name_str, meta_addr) in &names_meta { + let name = soroban_sdk::String::from_str(&env, name_str); + client.register(&owner, &name, &meta_addr); + } + + for (name_str, expected_meta) in &names_meta { + let name = soroban_sdk::String::from_str(&env, name_str); + let resolved = client.resolve(&name); + assert_eq!(&resolved, expected_meta); + } +} + +#[test] +fn migration_v1_multisig_keys_not_present_in_v0() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name = soroban_sdk::String::from_str(&env, "iris"); + let meta_addr = meta(&env, 0xCC); + + let client = WraithNamesContractClient::new(&env, &contract_id); + client.register(&owner, &name, &meta_addr); + + let name_hash_val = name_hash(&env, "iris"); + + env.as_contract(&contract_id, || { + let guard_key = DataKey::Guardians(name_hash_val.clone()); + let guardians: Option> = + env.storage().persistent().get(&guard_key); + assert!(guardians.is_none()); + + let recovery_key = DataKey::Recovery(name_hash_val.clone()); + let recovery: Option> = + env.storage().persistent().get(&recovery_key); + assert!(recovery.is_none()); + }); +} + +#[test] +fn migration_rollback_records_in_wrong_tier_invisible() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name_str = "jack"; + let meta_addr = meta(&env, 0xDD); + let name_hash_val = name_hash(&env, name_str); + + env.as_contract(&contract_id, || { + let entry = NameEntry { + name: soroban_sdk::String::from_str(&env, name_str), + stealth_meta_address: meta_addr.clone(), + owner: owner.clone(), + parent: None, + }; + + let key = DataKey::Name(name_hash_val.clone()); + env.storage().persistent().set(&key, &entry.clone()); + + let fake_entry = NameEntry { + name: soroban_sdk::String::from_str(&env, "fake"), + stealth_meta_address: meta(&env, 0xFF), + owner: Address::generate(&env), + parent: None, + }; + env.storage().instance().set(&key, &fake_entry); + }); + + env.as_contract(&contract_id, || { + let from_persistent: Option = env + .storage() + .persistent() + .get(&DataKey::Name(name_hash_val.clone())); + let from_instance: Option = env + .storage() + .instance() + .get(&DataKey::Name(name_hash_val.clone())); + + assert!(from_persistent.is_some()); + assert!(from_instance.is_some()); + }); +} + +#[test] +fn migration_incompatible_invalid_name_length_rejected() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let client = WraithNamesContractClient::new(&env, &contract_id); + + let short_name = soroban_sdk::String::from_str(&env, "ab"); + let meta_addr = meta(&env, 0xAA); + + let result = client.try_register(&owner, &short_name, &meta_addr); + assert_eq!(result, Err(Ok(NamesError::NameTooShort))); +} + +#[test] +fn migration_v1_event_schema_maintains_topic_layout() { + use soroban_sdk::testutils::Events; + + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name = soroban_sdk::String::from_str(&env, "karl"); + let meta_addr = meta(&env, 0xEE); + + let client = WraithNamesContractClient::new(&env, &contract_id); + client.register(&owner, &name, &meta_addr); + + let events = env.events().all(); + assert!(!events.is_empty()); +} + +#[test] +fn migration_v0_to_v1_admin_state_initialized() { + let env = env_with_ttl(); + let contract_id = env.register(WraithNamesContract, ()); + + let owner = Address::generate(&env); + let name = soroban_sdk::String::from_str(&env, "laura"); + let meta_addr = meta(&env, 0xFF); + + let client = WraithNamesContractClient::new(&env, &contract_id); + client.register(&owner, &name, &meta_addr); + + env.as_contract(&contract_id, || { + let admin: Option
= env.storage().instance().get(&DataKey::Admin); + let paused: Option = env.storage().instance().get(&DataKey::Paused); + + assert!(admin.is_some() || admin.is_none()); + assert!(paused.is_some() || paused.is_none()); + }); +} From a102dbdde7c8b71fc85e56bd0598bfc8bf340789 Mon Sep 17 00:00:00 2001 From: ryzen-xp Date: Sat, 3 Oct 2026 15:23:59 +0530 Subject: [PATCH 3/3] chore: update Cargo.lock and Cargo.toml for new migration tests - Added new packages for stealth-registry-v0, stealth-sender-v0, and wraith-names-v0 to Cargo.lock. - Updated Cargo.toml to include new migration test members for stealth-registry-v0, stealth-sender-v0, and wraith-names-v0. - Removed obsolete upgrade migration test files for stealth-sender and wraith-names to streamline testing structure. --- stellar/Cargo.lock | 34 ++ stellar/Cargo.toml | 4 + stellar/MIGRATION_TESTING.md | 28 ++ .../stealth-registry-v0/Cargo.toml | 12 + .../stealth-registry-v0/src/lib.rs | 82 +++++ .../stealth-sender-v0/Cargo.toml | 12 + .../stealth-sender-v0/src/lib.rs | 144 ++++++++ .../wraith-names-v0/Cargo.toml | 12 + .../wraith-names-v0/src/lib.rs | 248 ++++++++++++++ .../stealth-sender/tests/upgrade_migration.rs | 268 --------------- stellar/upgrade-migration-tests/Cargo.toml | 19 ++ stellar/upgrade-migration-tests/build.rs | 84 +++++ stellar/upgrade-migration-tests/src/lib.rs | 20 ++ .../tests/stealth_registry.rs | 51 +++ .../tests/stealth_sender.rs | 79 +++++ .../tests/wraith_names.rs | 147 ++++++++ .../wraith-names/tests/upgrade_migration.rs | 322 ------------------ 17 files changed, 976 insertions(+), 590 deletions(-) create mode 100644 stellar/MIGRATION_TESTING.md create mode 100644 stellar/migration-fixtures/stealth-registry-v0/Cargo.toml create mode 100644 stellar/migration-fixtures/stealth-registry-v0/src/lib.rs create mode 100644 stellar/migration-fixtures/stealth-sender-v0/Cargo.toml create mode 100644 stellar/migration-fixtures/stealth-sender-v0/src/lib.rs create mode 100644 stellar/migration-fixtures/wraith-names-v0/Cargo.toml create mode 100644 stellar/migration-fixtures/wraith-names-v0/src/lib.rs delete mode 100644 stellar/stealth-sender/tests/upgrade_migration.rs create mode 100644 stellar/upgrade-migration-tests/Cargo.toml create mode 100644 stellar/upgrade-migration-tests/build.rs create mode 100644 stellar/upgrade-migration-tests/src/lib.rs create mode 100644 stellar/upgrade-migration-tests/tests/stealth_registry.rs create mode 100644 stellar/upgrade-migration-tests/tests/stealth_sender.rs create mode 100644 stellar/upgrade-migration-tests/tests/wraith_names.rs delete mode 100644 stellar/wraith-names/tests/upgrade_migration.rs diff --git a/stellar/Cargo.lock b/stellar/Cargo.lock index 28740293..5e78db10 100644 --- a/stellar/Cargo.lock +++ b/stellar/Cargo.lock @@ -1633,6 +1633,13 @@ dependencies = [ "wraith-metrics", ] +[[package]] +name = "stealth-registry-v0" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "stealth-sender" version = "0.1.0" @@ -1643,6 +1650,13 @@ dependencies = [ "wraith-metrics", ] +[[package]] +name = "stealth-sender-v0" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "stealth-splitter" version = "0.1.0" @@ -1824,6 +1838,19 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "upgrade-migration-tests" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "stealth-registry", + "stealth-registry-v0", + "stealth-sender", + "stealth-sender-v0", + "wraith-names", + "wraith-names-v0", +] + [[package]] name = "version_check" version = "0.9.5" @@ -2165,6 +2192,13 @@ dependencies = [ "wraith-metrics", ] +[[package]] +name = "wraith-names-v0" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "wraith-stellar-bench" version = "0.1.0" diff --git a/stellar/Cargo.toml b/stellar/Cargo.toml index 5719fb08..9a1ecb8a 100644 --- a/stellar/Cargo.toml +++ b/stellar/Cargo.toml @@ -13,6 +13,10 @@ members = [ "wraith-metrics", "integration-tests", "contracts/governance", + "migration-fixtures/stealth-sender-v0", + "migration-fixtures/wraith-names-v0", + "migration-fixtures/stealth-registry-v0", + "upgrade-migration-tests", ] resolver = "2" diff --git a/stellar/MIGRATION_TESTING.md b/stellar/MIGRATION_TESTING.md new file mode 100644 index 00000000..86ad10f3 --- /dev/null +++ b/stellar/MIGRATION_TESTING.md @@ -0,0 +1,28 @@ +# Stellar contract upgrade migration tests + +These tests prove **real v0→v1 WASM upgrades** on a single contract ID using Soroban +`register_contract_wasm`, `upload_contract_wasm`, and `update_current_contract_wasm`. + +Fixtures under `migration-fixtures/` are frozen snapshots of the initial v0 layout +(instance-scoped names/registry data; sender with announcer-only `init`). Current +crates produce v1 WASM under test. + +## Running + +```bash +cd stellar +cargo test -p upgrade-migration-tests +``` + +The `upgrade-migration-tests` crate builds all fixture and production WASM in +`build.rs` before tests run. + +## Coverage matrix + +| Contract | Production upgrade path | What tests prove | +|---|---|---| +| `stealth-sender` | Timelock + multisig | v0 `init` state survives v1 WASM; v1 `send` and v1-only `init_multisig` work after swap | +| `wraith-names` | Timelock + multisig | Raw v0→v1 swap without storage migration is **incompatible** (instance → persistent); after operational instance→persistent copy, v1 reads succeed and new registrations use v1 schema (`parent: None`); v0 WASM rollback restores instance reads if no v1 writes occurred | +| `stealth-registry` | **Frozen (no upgrade in governance)** | WASM swap in test env shows v0 `instance()` records are invisible to v1 persistent reader; rolling back to v0 WASM restores reads — documents why production uses deploy-new rather than in-place upgrade | + +See also [MIGRATION_V0_TO_V1.md](./MIGRATION_V0_TO_V1.md) for operator checklists and indexer SQL. diff --git a/stellar/migration-fixtures/stealth-registry-v0/Cargo.toml b/stellar/migration-fixtures/stealth-registry-v0/Cargo.toml new file mode 100644 index 00000000..bc75d0a6 --- /dev/null +++ b/stellar/migration-fixtures/stealth-registry-v0/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "stealth-registry-v0" +version = "0.1.0" +edition = "2021" +publish = false +description = "Frozen v0 stealth-registry bytecode fixture for storage migration tests" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/stellar/migration-fixtures/stealth-registry-v0/src/lib.rs b/stellar/migration-fixtures/stealth-registry-v0/src/lib.rs new file mode 100644 index 00000000..41249dec --- /dev/null +++ b/stellar/migration-fixtures/stealth-registry-v0/src/lib.rs @@ -0,0 +1,82 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Bytes, Env, +}; + +/// Storage keys. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Maps (registrant, scheme_id) to their stealth meta-address (64 bytes: + /// spending_pubkey || viewing_pubkey). + MetaAddress(Address, u32), +} + +/// Errors that the registry can produce. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum RegistryError { + /// The supplied stealth meta-address is not exactly 64 bytes. + InvalidMetaAddressLength = 1, + /// No stealth meta-address has been registered for the given address and scheme. + NotRegistered = 2, +} + +#[contract] +pub struct StealthRegistryContract; + +#[contractimpl] +impl StealthRegistryContract { + /// Register or update a stealth meta-address. + /// + /// # Arguments + /// * `registrant` - The address whose meta-address is being set (must authorise). + /// * `scheme_id` - The stealth address scheme identifier. + /// * `stealth_meta_address` - 64-byte value: `spending_pubkey || viewing_pubkey`. + pub fn register_keys( + env: Env, + registrant: Address, + scheme_id: u32, + stealth_meta_address: Bytes, + ) -> Result<(), RegistryError> { + // Require authorisation from the registrant. + registrant.require_auth(); + + // Validate length. + if stealth_meta_address.len() != 64 { + return Err(RegistryError::InvalidMetaAddressLength); + } + + // Persist. + let key = DataKey::MetaAddress(registrant.clone(), scheme_id); + env.storage().instance().set(&key, &stealth_meta_address); + + // Emit event. + env.events().publish( + (symbol_short!("register"), registrant, scheme_id), + stealth_meta_address, + ); + + Ok(()) + } + + /// Look up a previously registered stealth meta-address. + /// + /// # Arguments + /// * `registrant` - The address to look up. + /// * `scheme_id` - The stealth address scheme identifier. + pub fn stealth_meta_address_of( + env: Env, + registrant: Address, + scheme_id: u32, + ) -> Result { + let key = DataKey::MetaAddress(registrant, scheme_id); + env.storage() + .instance() + .get(&key) + .ok_or(RegistryError::NotRegistered) + } +} + diff --git a/stellar/migration-fixtures/stealth-sender-v0/Cargo.toml b/stellar/migration-fixtures/stealth-sender-v0/Cargo.toml new file mode 100644 index 00000000..de57b7ff --- /dev/null +++ b/stellar/migration-fixtures/stealth-sender-v0/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "stealth-sender-v0" +version = "0.1.0" +edition = "2021" +publish = false +description = "Frozen v0 stealth-sender bytecode fixture for upgrade migration tests" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/stellar/migration-fixtures/stealth-sender-v0/src/lib.rs b/stellar/migration-fixtures/stealth-sender-v0/src/lib.rs new file mode 100644 index 00000000..e895373a --- /dev/null +++ b/stellar/migration-fixtures/stealth-sender-v0/src/lib.rs @@ -0,0 +1,144 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Bytes, BytesN, Env, Vec, +}; + +/// Storage keys. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// The address of the deployed StealthAnnouncer contract. + Announcer, +} + +/// Errors that the sender contract can produce. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum SenderError { + /// The contract has already been initialised. + AlreadyInitialized = 1, + /// The contract has not been initialised yet. + NotInitialized = 2, + /// The batch input vectors have mismatched lengths. + LengthMismatch = 3, +} + +mod announcer_client { + use soroban_sdk::{Address, Bytes, BytesN, Env, IntoVal}; + + pub fn announce( + env: &Env, + announcer: &Address, + scheme_id: u32, + stealth_address: &Address, + ephemeral_pub_key: &BytesN<32>, + metadata: &Bytes, + ) { + let _: () = env.invoke_contract( + announcer, + &soroban_sdk::symbol_short!("announce"), + soroban_sdk::vec![ + env, + scheme_id.into_val(env), + stealth_address.into_val(env), + ephemeral_pub_key.into_val(env), + metadata.into_val(env), + ], + ); + } +} + +#[contract] +pub struct StealthSenderContract; + +#[contractimpl] +impl StealthSenderContract { + pub fn init(env: Env, announcer: Address) -> Result<(), SenderError> { + if env.storage().instance().has(&DataKey::Announcer) { + return Err(SenderError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Announcer, &announcer); + Ok(()) + } + + pub fn send( + env: Env, + sender: Address, + token: Address, + amount: i128, + scheme_id: u32, + stealth_address: Address, + ephemeral_pub_key: BytesN<32>, + metadata: Bytes, + ) -> Result<(), SenderError> { + sender.require_auth(); + + let announcer: Address = env + .storage() + .instance() + .get(&DataKey::Announcer) + .ok_or(SenderError::NotInitialized)?; + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&sender, &stealth_address, &amount); + + announcer_client::announce( + &env, + &announcer, + scheme_id, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); + + Ok(()) + } + + pub fn batch_send( + env: Env, + sender: Address, + token: Address, + scheme_id: u32, + stealth_addresses: Vec
, + ephemeral_pub_keys: Vec>, + metadatas: Vec, + amounts: Vec, + ) -> Result<(), SenderError> { + sender.require_auth(); + + let len = stealth_addresses.len(); + if ephemeral_pub_keys.len() != len || metadatas.len() != len || amounts.len() != len { + return Err(SenderError::LengthMismatch); + } + + let announcer: Address = env + .storage() + .instance() + .get(&DataKey::Announcer) + .ok_or(SenderError::NotInitialized)?; + + let token_client = token::Client::new(&env, &token); + + for i in 0..len { + let stealth_address = stealth_addresses.get(i).unwrap(); + let ephemeral_pub_key = ephemeral_pub_keys.get(i).unwrap(); + let metadata = metadatas.get(i).unwrap(); + let amount = amounts.get(i).unwrap(); + + token_client.transfer(&sender, &stealth_address, &amount); + + announcer_client::announce( + &env, + &announcer, + scheme_id, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); + } + + Ok(()) + } +} diff --git a/stellar/migration-fixtures/wraith-names-v0/Cargo.toml b/stellar/migration-fixtures/wraith-names-v0/Cargo.toml new file mode 100644 index 00000000..dd803358 --- /dev/null +++ b/stellar/migration-fixtures/wraith-names-v0/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wraith-names-v0" +version = "0.1.0" +edition = "2021" +publish = false +description = "Frozen v0 wraith-names bytecode fixture for upgrade migration tests" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/stellar/migration-fixtures/wraith-names-v0/src/lib.rs b/stellar/migration-fixtures/wraith-names-v0/src/lib.rs new file mode 100644 index 00000000..eb5a6d2d --- /dev/null +++ b/stellar/migration-fixtures/wraith-names-v0/src/lib.rs @@ -0,0 +1,248 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, Env, + String, +}; + +/// Storage keys. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Maps name hash (BytesN<32>) to NameEntry. + Name(BytesN<32>), + /// Reverse lookup: meta-address hash (BytesN<32>) to name hash (BytesN<32>). + Reverse(BytesN<32>), +} + +/// A registered name entry. +#[contracttype] +#[derive(Clone)] +pub struct NameEntry { + /// The human-readable name. + pub name: String, + /// The 64-byte stealth meta-address (spending_pubkey || viewing_pubkey). + pub stealth_meta_address: Bytes, + /// The registrant address (for auth). + pub owner: Address, +} + +/// Errors. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum NamesError { + NameTaken = 1, + NameTooShort = 2, + NameTooLong = 3, + InvalidNameCharacter = 4, + InvalidMetaAddress = 5, + NameNotFound = 6, + NotOwner = 7, +} + +#[contract] +pub struct WraithNamesContract; + +#[contractimpl] +impl WraithNamesContract { + /// Register a name mapped to a stealth meta-address. + /// The caller (owner) must authorize. Ownership is tied to the caller's address. + /// + /// # Arguments + /// * `owner` - The address registering the name (must authorize). + /// * `name` - The human-readable name (lowercase alphanumeric, 3-32 chars). + /// * `stealth_meta_address` - 64-byte stealth meta-address. + pub fn register( + env: Env, + owner: Address, + name: String, + stealth_meta_address: Bytes, + ) -> Result<(), NamesError> { + owner.require_auth(); + + Self::validate_name(&env, &name)?; + if stealth_meta_address.len() != 64 { + return Err(NamesError::InvalidMetaAddress); + } + + let name_hash = Self::hash_name(&env, &name); + let name_key = DataKey::Name(name_hash.clone()); + + // Check not taken + if env.storage().instance().has(&name_key) { + return Err(NamesError::NameTaken); + } + + let entry = NameEntry { + name: name.clone(), + stealth_meta_address: stealth_meta_address.clone(), + owner: owner.clone(), + }; + + env.storage().instance().set(&name_key, &entry); + + // Reverse lookup + let meta_hash = BytesN::from_array(&env, &env.crypto().sha256(&stealth_meta_address).to_array()); + env.storage() + .instance() + .set(&DataKey::Reverse(meta_hash), &name_hash); + + env.events().publish( + (symbol_short!("register"), name_hash), + (name, stealth_meta_address), + ); + + Ok(()) + } + + /// Update the meta-address for an existing name. + /// Only the current owner can update. + pub fn update( + env: Env, + owner: Address, + name: String, + new_meta_address: Bytes, + ) -> Result<(), NamesError> { + owner.require_auth(); + + if new_meta_address.len() != 64 { + return Err(NamesError::InvalidMetaAddress); + } + + let name_hash = Self::hash_name(&env, &name); + let name_key = DataKey::Name(name_hash.clone()); + + let entry: NameEntry = env + .storage() + .instance() + .get(&name_key) + .ok_or(NamesError::NameNotFound)?; + + if entry.owner != owner { + return Err(NamesError::NotOwner); + } + + // Remove old reverse + let old_meta_hash = BytesN::from_array(&env, &env.crypto().sha256(&entry.stealth_meta_address).to_array()); + env.storage() + .instance() + .remove(&DataKey::Reverse(old_meta_hash)); + + // Update + let new_entry = NameEntry { + name: name.clone(), + stealth_meta_address: new_meta_address.clone(), + owner, + }; + env.storage().instance().set(&name_key, &new_entry); + + // New reverse + let new_meta_hash = BytesN::from_array(&env, &env.crypto().sha256(&new_meta_address).to_array()); + env.storage() + .instance() + .set(&DataKey::Reverse(new_meta_hash), &name_hash); + + env.events().publish( + (symbol_short!("register"), name_hash), + (name, new_meta_address), + ); + + Ok(()) + } + + /// Release a name, making it available again. + pub fn release(env: Env, owner: Address, name: String) -> Result<(), NamesError> { + owner.require_auth(); + + let name_hash = Self::hash_name(&env, &name); + let name_key = DataKey::Name(name_hash.clone()); + + let entry: NameEntry = env + .storage() + .instance() + .get(&name_key) + .ok_or(NamesError::NameNotFound)?; + + if entry.owner != owner { + return Err(NamesError::NotOwner); + } + + // Remove reverse + let meta_hash = BytesN::from_array(&env, &env.crypto().sha256(&entry.stealth_meta_address).to_array()); + env.storage() + .instance() + .remove(&DataKey::Reverse(meta_hash)); + + // Remove name + env.storage().instance().remove(&name_key); + + env.events() + .publish((symbol_short!("release"), name_hash), name); + + Ok(()) + } + + /// Resolve a name to its stealth meta-address. + pub fn resolve(env: Env, name: String) -> Result { + let name_hash = Self::hash_name(&env, &name); + let entry: NameEntry = env + .storage() + .instance() + .get(&DataKey::Name(name_hash)) + .ok_or(NamesError::NameNotFound)?; + Ok(entry.stealth_meta_address) + } + + /// Reverse lookup: find the name for a given stealth meta-address. + pub fn name_of(env: Env, stealth_meta_address: Bytes) -> Result { + let meta_hash = BytesN::from_array(&env, &env.crypto().sha256(&stealth_meta_address).to_array()); + let name_hash: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::Reverse(meta_hash)) + .ok_or(NamesError::NameNotFound)?; + let entry: NameEntry = env + .storage() + .instance() + .get(&DataKey::Name(name_hash)) + .ok_or(NamesError::NameNotFound)?; + Ok(entry.name) + } + + /// Hash a name string to BytesN<32> for use as storage key. + fn hash_name(env: &Env, name: &String) -> BytesN<32> { + let len = name.len() as usize; + let mut buf = [0u8; 32]; + if len > 0 { + name.copy_into_slice(&mut buf[..len]); + } + let bytes = Bytes::from_slice(env, &buf[..len]); + BytesN::from_array(env, &env.crypto().sha256(&bytes).to_array()) + } + + /// Validate name: 3-32 chars, lowercase alphanumeric only. + fn validate_name(_env: &Env, name: &String) -> Result<(), NamesError> { + let len = name.len() as usize; + if len < 3 { + return Err(NamesError::NameTooShort); + } + if len > 32 { + return Err(NamesError::NameTooLong); + } + + let mut buf = [0u8; 32]; + name.copy_into_slice(&mut buf[..len]); + for i in 0..len { + let c = buf[i]; + let is_lower = c >= b'a' && c <= b'z'; + let is_digit = c >= b'0' && c <= b'9'; + if !is_lower && !is_digit { + return Err(NamesError::InvalidNameCharacter); + } + } + + Ok(()) + } +} + diff --git a/stellar/stealth-sender/tests/upgrade_migration.rs b/stellar/stealth-sender/tests/upgrade_migration.rs deleted file mode 100644 index 45817e20..00000000 --- a/stellar/stealth-sender/tests/upgrade_migration.rs +++ /dev/null @@ -1,268 +0,0 @@ -#![cfg(test)] - -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Bytes, BytesN, Env, -}; -use stealth_sender::{DataKey, SenderError, StealthSenderContract, StealthSenderContractClient}; - -const TTL_THRESHOLD: u32 = 17_280; -const TTL_EXTEND_TO: u32 = 518_400; - -fn env_with_ttl() -> Env { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|li| { - li.min_persistent_entry_ttl = TTL_EXTEND_TO; - li.max_entry_ttl = TTL_EXTEND_TO * 2; - }); - env -} - -fn mock_announcer(env: &Env) -> Address { - env.register(MockAnnouncer, ()) -} - -#[test] -fn migration_v0_snapshot_announcer_only_readable_by_v1() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let announcer_addr = mock_announcer(&env); - let admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - env.storage() - .instance() - .set(&DataKey::Announcer, &announcer_addr); - env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::Paused, &false); - }); - - let _client = StealthSenderContractClient::new(&env, &contract_id); - - env.as_contract(&contract_id, || { - let stored: Option
= env.storage().instance().get(&DataKey::Announcer); - assert_eq!(stored, Some(announcer_addr.clone())); - - let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); - assert_eq!(stored_admin, Some(admin.clone())); - - let stored_paused: Option = env.storage().instance().get(&DataKey::Paused); - assert_eq!(stored_paused, Some(false)); - }); -} - -#[test] -fn migration_v0_multiple_snapshot_records_readable() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let _announcer = mock_announcer(&env); - let admins: Vec
= (0..3).map(|_| Address::generate(&env)).collect(); - - for (i, admin) in admins.iter().enumerate() { - env.as_contract(&contract_id, || { - let _key_suffix = i as u32; - let key = DataKey::Admin; - env.storage().instance().set(&key, admin); - }); - } - - env.as_contract(&contract_id, || { - let stored: Option
= env.storage().instance().get(&DataKey::Admin); - assert_eq!(stored, Some(admins[2].clone())); - }); -} - -#[test] -fn migration_v1_new_keys_written_to_instance() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let announcer = mock_announcer(&env); - let admin = Address::generate(&env); - let fee_recipient = Address::generate(&env); - - let client = StealthSenderContractClient::new(&env, &contract_id); - client.init(&announcer, &None, &Some(fee_recipient.clone()), &10, &admin); - - env.as_contract(&contract_id, || { - let stored_announcer: Option
= env.storage().instance().get(&DataKey::Announcer); - assert_eq!(stored_announcer, Some(announcer.clone())); - - let stored_fee_recipient: Option> = - env.storage().instance().get(&DataKey::FeeRecipient); - assert_eq!(stored_fee_recipient, Some(Some(fee_recipient.clone()))); - - let stored_fee_bps: Option = env.storage().instance().get(&DataKey::FeeBasisPoints); - assert_eq!(stored_fee_bps, Some(10)); - - let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); - assert_eq!(stored_admin, Some(admin.clone())); - }); -} - -#[test] -fn migration_v1_schema_invariant_unchanged() { - let env = env_with_ttl(); - let contract_id1 = env.register(StealthSenderContract, ()); - let contract_id2 = env.register(StealthSenderContract, ()); - - let announcer1 = mock_announcer(&env); - let announcer2 = mock_announcer(&env); - let admin = Address::generate(&env); - - let client1 = StealthSenderContractClient::new(&env, &contract_id1); - client1.init(&announcer1, &None, &None, &0, &admin); - - let client2 = StealthSenderContractClient::new(&env, &contract_id2); - client2.init(&announcer2, &None, &None, &0, &admin); - - env.as_contract(&contract_id1, || { - let key = DataKey::Announcer; - let stored1: Option
= env.storage().instance().get(&key); - assert_eq!(stored1, Some(announcer1.clone())); - }); - - env.as_contract(&contract_id2, || { - let key = DataKey::Announcer; - let stored2: Option
= env.storage().instance().get(&key); - assert_eq!(stored2, Some(announcer2.clone())); - }); -} - -#[test] -fn migration_post_upgrade_reads_survive_ttl_threshold() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let announcer = mock_announcer(&env); - let admin = Address::generate(&env); - - let client = StealthSenderContractClient::new(&env, &contract_id); - client.init(&announcer, &None, &None, &0, &admin); - - env.ledger().with_mut(|li| { - li.sequence_number += TTL_THRESHOLD + 1; - }); - - env.as_contract(&contract_id, || { - let stored: Option
= env.storage().instance().get(&DataKey::Announcer); - assert_eq!(stored, Some(announcer.clone())); - }); -} - -#[test] -fn migration_v1_fee_config_validation_enforced_on_write() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let announcer = mock_announcer(&env); - let admin = Address::generate(&env); - let fee_recipient = Address::generate(&env); - - let client = StealthSenderContractClient::new(&env, &contract_id); - - let init_with_fee_no_recipient = client.try_init(&announcer, &None, &None, &10, &admin); - assert_eq!( - init_with_fee_no_recipient, - Err(Ok(SenderError::InvalidFeeConfig)) - ); - - let init_with_fee_exceeds_cap = - client.try_init(&announcer, &None, &Some(fee_recipient.clone()), &51, &admin); - assert_eq!( - init_with_fee_exceeds_cap, - Err(Ok(SenderError::InvalidFeeConfig)) - ); - - let init_valid = client.try_init(&announcer, &None, &Some(fee_recipient.clone()), &50, &admin); - assert!(init_valid.is_ok()); -} - -#[test] -fn migration_rollback_records_in_wrong_tier_invisible_to_v1() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let announcer = mock_announcer(&env); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - - env.as_contract(&contract_id, || { - env.storage() - .instance() - .set(&DataKey::Announcer, &announcer); - env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::Paused, &false); - env.storage() - .persistent() - .set(&DataKey::Announcer, &attacker); - }); - - env.as_contract(&contract_id, || { - let from_instance: Option
= env.storage().instance().get(&DataKey::Announcer); - let from_persistent: Option
= env.storage().persistent().get(&DataKey::Announcer); - - assert_eq!(from_instance, Some(announcer.clone())); - assert_eq!(from_persistent, Some(attacker.clone())); - }); -} - -#[test] -fn migration_incompatible_v1_keys_rejected_on_invalid_config() { - let env = env_with_ttl(); - let contract_id = env.register(StealthSenderContract, ()); - - let announcer = mock_announcer(&env); - let admin = Address::generate(&env); - - let client = StealthSenderContractClient::new(&env, &contract_id); - - let init_already = client.try_init(&announcer, &None, &None, &0, &admin); - assert!(init_already.is_ok()); - - let init_duplicate = client.try_init(&announcer, &None, &None, &0, &admin); - assert_eq!(init_duplicate, Err(Ok(SenderError::AlreadyInitialized))); -} - -#[test] -fn migration_batch_v0_records_all_readable_after_v1_init() { - let env = env_with_ttl(); - let contract_ids: Vec
= (0..3) - .map(|_| env.register(StealthSenderContract, ())) - .collect(); - - let announcer = mock_announcer(&env); - let admin = Address::generate(&env); - - for contract_id in &contract_ids { - let client = StealthSenderContractClient::new(&env, contract_id); - client.init(&announcer, &None, &None, &0, &admin); - } - - for contract_id in &contract_ids { - env.as_contract(contract_id, || { - let stored: Option
= env.storage().instance().get(&DataKey::Announcer); - assert_eq!(stored, Some(announcer.clone())); - }); - } -} - -use soroban_sdk::{contract, contractimpl}; - -#[contract] -pub struct MockAnnouncer; - -#[contractimpl] -impl MockAnnouncer { - pub fn announce( - _env: Env, - _scheme_id: u32, - _stealth_address: Address, - _ephemeral_pub_key: BytesN<32>, - _metadata: Bytes, - ) { - } -} diff --git a/stellar/upgrade-migration-tests/Cargo.toml b/stellar/upgrade-migration-tests/Cargo.toml new file mode 100644 index 00000000..d3757d6e --- /dev/null +++ b/stellar/upgrade-migration-tests/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "upgrade-migration-tests" +version = "0.1.0" +edition = "2021" +publish = false +description = "v0→v1 Soroban WASM upgrade migration integration tests" +build = "build.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +stealth-sender = { path = "../stealth-sender" } +wraith-names = { path = "../wraith-names" } +stealth-registry = { path = "../stealth-registry" } +stealth-sender-v0 = { path = "../migration-fixtures/stealth-sender-v0" } +wraith-names-v0 = { path = "../migration-fixtures/wraith-names-v0" } +stealth-registry-v0 = { path = "../migration-fixtures/stealth-registry-v0" } diff --git a/stellar/upgrade-migration-tests/build.rs b/stellar/upgrade-migration-tests/build.rs new file mode 100644 index 00000000..4a394f6f --- /dev/null +++ b/stellar/upgrade-migration-tests/build.rs @@ -0,0 +1,84 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +const WASM_CRATES: &[(&str, &str)] = &[ + ( + "migration-fixtures/stealth-sender-v0", + "stealth_sender_v0", + ), + ("migration-fixtures/wraith-names-v0", "wraith_names_v0"), + ( + "migration-fixtures/stealth-registry-v0", + "stealth_registry_v0", + ), + ("stealth-sender", "stealth_sender"), + ("wraith-names", "wraith_names"), + ("stealth-registry", "stealth_registry"), +]; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("upgrade-migration-tests must live under stellar/") + .to_path_buf() +} + +fn build_wasm(manifest: &Path) { + let status = Command::new("cargo") + .arg("build") + .arg("--release") + .arg("--target") + .arg("wasm32-unknown-unknown") + .arg("--manifest-path") + .arg(manifest) + .status() + .unwrap_or_else(|e| panic!("failed to spawn cargo build for {}: {e}", manifest.display())); + + assert!( + status.success(), + "wasm build failed for {}", + manifest.display() + ); +} + +fn main() { + let root = workspace_root(); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let release_wasm = root.join("target/wasm32-unknown-unknown/release"); + + let mut manifest_lines = String::from("// @generated by upgrade-migration-tests/build.rs\n"); + + for (crate_path, stem) in WASM_CRATES { + let manifest = root.join(crate_path).join("Cargo.toml"); + println!("cargo:rerun-if-changed={}", manifest.display()); + let src_dir = root.join(crate_path).join("src"); + if src_dir.is_dir() { + println!("cargo:rerun-if-changed={}", src_dir.display()); + } + + build_wasm(&manifest); + + let wasm_src = release_wasm.join(format!("{stem}.wasm")); + assert!( + wasm_src.is_file(), + "expected wasm artifact at {}", + wasm_src.display() + ); + + let wasm_dst = out_dir.join(format!("{stem}.wasm")); + std::fs::copy(&wasm_src, &wasm_dst).unwrap_or_else(|e| { + panic!( + "copy {} -> {}: {e}", + wasm_src.display(), + wasm_dst.display() + ) + }); + + manifest_lines.push_str(&format!( + "pub const {}: &[u8] = include_bytes!(\"{stem}.wasm\");\n", + stem.to_uppercase() + )); + } + + std::fs::write(out_dir.join("wasm_manifest.rs"), manifest_lines).unwrap(); +} diff --git a/stellar/upgrade-migration-tests/src/lib.rs b/stellar/upgrade-migration-tests/src/lib.rs new file mode 100644 index 00000000..28b89e7e --- /dev/null +++ b/stellar/upgrade-migration-tests/src/lib.rs @@ -0,0 +1,20 @@ +#![allow(clippy::module_name_repetitions)] + +use soroban_sdk::{Address, BytesN, Env}; + +pub mod wasm { + include!(concat!(env!("OUT_DIR"), "/wasm_manifest.rs")); +} + +/// Upload `wasm` bytes and replace the contract at `contract_id` with that implementation. +pub fn upgrade_contract_wasm(env: &Env, contract_id: &Address, wasm: &[u8]) { + let wasm_hash = env.deployer().upload_contract_wasm(wasm); + env.as_contract(contract_id, || { + env.deployer().update_current_contract_wasm(&wasm_hash); + }); +} + +/// Deploy a contract from pinned v0/v1 WASM bytecode. +pub fn deploy_wasm(env: &Env, wasm: &[u8]) -> Address { + env.register_contract_wasm(None, wasm) +} diff --git a/stellar/upgrade-migration-tests/tests/stealth_registry.rs b/stellar/upgrade-migration-tests/tests/stealth_registry.rs new file mode 100644 index 00000000..c3b6e8cc --- /dev/null +++ b/stellar/upgrade-migration-tests/tests/stealth_registry.rs @@ -0,0 +1,51 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env}; +use stealth_registry::{RegistryError, StealthRegistryContractClient}; +use stealth_registry_v0::StealthRegistryContractClient as StealthRegistryV0Client; +use upgrade_migration_tests::{deploy_wasm, upgrade_contract_wasm, wasm}; + +#[test] +fn v0_instance_storage_invisible_to_v1_persistent_reader_after_wasm_swap() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = deploy_wasm(&env, wasm::STEALTH_REGISTRY_V0); + let v0 = StealthRegistryV0Client::new(&env, &contract_id); + + let registrant = Address::generate(&env); + let scheme_id: u32 = 1; + let meta = Bytes::from_slice(&env, &[42u8; 64]); + v0.register_keys(®istrant, &scheme_id, &meta).unwrap(); + + upgrade_contract_wasm(&env, &contract_id, wasm::STEALTH_REGISTRY); + + let v1 = StealthRegistryContractClient::new(&env, &contract_id); + assert_eq!( + v1.try_stealth_meta_address_of(®istrant, &scheme_id), + Err(Ok(RegistryError::NotRegistered)) + ); +} + +#[test] +fn registry_wasm_rollback_restores_v0_instance_reads() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = deploy_wasm(&env, wasm::STEALTH_REGISTRY_V0); + let v0 = StealthRegistryV0Client::new(&env, &contract_id); + + let registrant = Address::generate(&env); + let scheme_id: u32 = 2; + let meta = Bytes::from_slice(&env, &[9u8; 64]); + v0.register_keys(®istrant, &scheme_id, &meta).unwrap(); + + upgrade_contract_wasm(&env, &contract_id, wasm::STEALTH_REGISTRY); + upgrade_contract_wasm(&env, &contract_id, wasm::STEALTH_REGISTRY_V0); + + let v0_after = StealthRegistryV0Client::new(&env, &contract_id); + assert_eq!( + v0_after.stealth_meta_address_of(®istrant, &scheme_id).unwrap(), + meta + ); +} diff --git a/stellar/upgrade-migration-tests/tests/stealth_sender.rs b/stellar/upgrade-migration-tests/tests/stealth_sender.rs new file mode 100644 index 00000000..b36e7337 --- /dev/null +++ b/stellar/upgrade-migration-tests/tests/stealth_sender.rs @@ -0,0 +1,79 @@ +#![cfg(test)] + +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, Bytes, BytesN, Env, Vec, +}; +use stealth_sender::{SenderError, StealthSenderContractClient}; +use stealth_sender_v0::StealthSenderContractClient as StealthSenderV0Client; +use upgrade_migration_tests::{deploy_wasm, upgrade_contract_wasm, wasm}; + +#[contract] +pub struct MockAnnouncer; + +#[contractimpl] +impl MockAnnouncer { + pub fn announce( + _env: Env, + _scheme_id: u32, + _stealth_address: Address, + _ephemeral_pub_key: BytesN<32>, + _metadata: Bytes, + ) { + } +} + +fn mock_announcer(env: &Env) -> Address { + env.register(MockAnnouncer, ()) +} + +#[test] +fn v0_to_v1_upgrade_preserves_announcer_and_allows_v1_send() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = 518_400; + li.max_entry_ttl = 1_036_800; + }); + + let contract_id = deploy_wasm(&env, wasm::STEALTH_SENDER_V0); + let v0 = StealthSenderV0Client::new(&env, &contract_id); + + let announcer = mock_announcer(&env); + v0.init(&announcer).unwrap(); + + upgrade_contract_wasm(&env, &contract_id, wasm::STEALTH_SENDER); + + let v1 = StealthSenderContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + assert_eq!( + v1.try_init(&announcer, &None, &None, &0, &admin), + Err(Ok(SenderError::AlreadyInitialized)) + ); + + let sender = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let stealth = Address::generate(&env); + let eph = BytesN::from_array(&env, &[7u8; 32]); + let metadata = Bytes::from_slice(&env, &[0u8; 1]); + + v1.send( + &sender, + &token, + &100, + &1, + &stealth, + &eph, + &metadata, + ) + .unwrap(); + + let signers = Vec::from_array(&env, [Address::generate(&env)]); + v1.init_multisig(&signers, &1).unwrap(); + assert_eq!(v1.signers().len(), 1); +} diff --git a/stellar/upgrade-migration-tests/tests/wraith_names.rs b/stellar/upgrade-migration-tests/tests/wraith_names.rs new file mode 100644 index 00000000..87158607 --- /dev/null +++ b/stellar/upgrade-migration-tests/tests/wraith_names.rs @@ -0,0 +1,147 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Bytes, BytesN, Env, String, +}; +use upgrade_migration_tests::{deploy_wasm, upgrade_contract_wasm, wasm}; +use wraith_names::{DataKey as V1NameKey, NameEntry as V1NameEntry, NamesError, WraithNamesContractClient}; +use wraith_names_v0::{ + DataKey as V0NameKey, NameEntry as V0NameEntry, WraithNamesContractClient as WraithNamesV0Client, +}; + +const TTL_THRESHOLD: u32 = 17_280; +const TTL_EXTEND_TO: u32 = 518_400; + +fn env_with_ttl() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = TTL_EXTEND_TO; + li.max_entry_ttl = TTL_EXTEND_TO * 2; + }); + env +} + +fn meta(env: &Env, seed: u8) -> Bytes { + Bytes::from_slice(env, &[seed; 64]) +} + +fn hash_name(env: &Env, name: &String) -> BytesN<32> { + let len = name.len() as usize; + let mut buf = [0u8; 32]; + if len > 0 { + name.copy_into_slice(&mut buf[..len]); + } + let name_bytes = Bytes::from_slice(env, &buf[..len]); + BytesN::from_array(env, &env.crypto().sha256(&name_bytes).to_array()) +} + +/// Operational migration: copy v0 instance records into v1 persistent layout before WASM swap. +fn migrate_instance_records_to_persistent(env: &Env, contract_id: &Address, name: &String) { + let name_hash = hash_name(env, name); + env.as_contract(contract_id, || { + let name_key_v0 = V0NameKey::Name(name_hash.clone()); + let entry: V0NameEntry = env + .storage() + .instance() + .get(&name_key_v0) + .expect("v0 name entry must exist in instance storage"); + + let meta_hash = BytesN::from_array(env, &env.crypto().sha256(&entry.stealth_meta_address).to_array()); + let reverse_hash: BytesN<32> = env + .storage() + .instance() + .get(&V0NameKey::Reverse(meta_hash.clone())) + .expect("v0 reverse entry must exist"); + + let v1_entry = V1NameEntry { + name: entry.name.clone(), + stealth_meta_address: entry.stealth_meta_address.clone(), + owner: entry.owner.clone(), + parent: None, + }; + + let name_key_v1 = V1NameKey::Name(name_hash.clone()); + let reverse_key_v1 = V1NameKey::Reverse(meta_hash); + + env.storage().persistent().set(&name_key_v1, &v1_entry); + env.storage().persistent().set(&reverse_key_v1, &reverse_hash); + env.storage() + .persistent() + .extend_ttl(&name_key_v1, TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&reverse_key_v1, TTL_THRESHOLD, TTL_EXTEND_TO); + }); +} + +#[test] +fn v0_to_v1_upgrade_without_storage_migration_is_incompatible() { + let env = env_with_ttl(); + let contract_id = deploy_wasm(&env, wasm::WRAITH_NAMES_V0); + let v0 = WraithNamesV0Client::new(&env, &contract_id); + + let owner = Address::generate(&env); + let name = String::from_str(&env, "alice"); + let meta_addr = meta(&env, 0xAA); + v0.register(&owner, &name, &meta_addr).unwrap(); + + upgrade_contract_wasm(&env, &contract_id, wasm::WRAITH_NAMES); + + let v1 = WraithNamesContractClient::new(&env, &contract_id); + assert_eq!( + v1.try_resolve(&name), + Err(Ok(NamesError::NameNotFound)) + ); +} + +#[test] +fn v0_to_v1_upgrade_after_storage_migration_preserves_reads_and_v1_writes() { + let env = env_with_ttl(); + let contract_id = deploy_wasm(&env, wasm::WRAITH_NAMES_V0); + let v0 = WraithNamesV0Client::new(&env, &contract_id); + + let owner = Address::generate(&env); + let name = String::from_str(&env, "bob"); + let meta_addr = meta(&env, 0xBB); + v0.register(&owner, &name, &meta_addr).unwrap(); + + migrate_instance_records_to_persistent(&env, &contract_id, &name); + upgrade_contract_wasm(&env, &contract_id, wasm::WRAITH_NAMES); + + let v1 = WraithNamesContractClient::new(&env, &contract_id); + assert_eq!(v1.resolve(&name).unwrap(), meta_addr); + + let new_name = String::from_str(&env, "carol"); + let new_meta = meta(&env, 0xCC); + v1.register(&owner, &new_name, &new_meta).unwrap(); + + let name_hash = hash_name(&env, &new_name); + env.as_contract(&contract_id, || { + let stored: V1NameEntry = env + .storage() + .persistent() + .get(&V1NameKey::Name(name_hash)) + .unwrap(); + assert!(stored.parent.is_none()); + }); +} + +#[test] +fn v0_to_v1_rollback_restores_v0_reads_when_no_v1_writes() { + let env = env_with_ttl(); + let contract_id = deploy_wasm(&env, wasm::WRAITH_NAMES_V0); + let v0 = WraithNamesV0Client::new(&env, &contract_id); + + let owner = Address::generate(&env); + let name = String::from_str(&env, "dave"); + let meta_addr = meta(&env, 0xDD); + v0.register(&owner, &name, &meta_addr).unwrap(); + + upgrade_contract_wasm(&env, &contract_id, wasm::WRAITH_NAMES); + upgrade_contract_wasm(&env, &contract_id, wasm::WRAITH_NAMES_V0); + + let v0_after = WraithNamesV0Client::new(&env, &contract_id); + assert_eq!(v0_after.resolve(&name).unwrap(), meta_addr); +} diff --git a/stellar/wraith-names/tests/upgrade_migration.rs b/stellar/wraith-names/tests/upgrade_migration.rs deleted file mode 100644 index 7b8ea4a3..00000000 --- a/stellar/wraith-names/tests/upgrade_migration.rs +++ /dev/null @@ -1,322 +0,0 @@ -#![cfg(test)] - -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Bytes, BytesN, Env, -}; -use wraith_names::{ - DataKey, NameEntry, NamesError, WraithNamesContract, WraithNamesContractClient, -}; - -const TTL_THRESHOLD: u32 = 17_280; -const TTL_EXTEND_TO: u32 = 518_400; - -fn env_with_ttl() -> Env { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|li| { - li.min_persistent_entry_ttl = TTL_EXTEND_TO; - li.max_entry_ttl = TTL_EXTEND_TO * 2; - }); - env -} - -fn name_hash(env: &Env, name: &str) -> BytesN<32> { - let name_bytes = name.as_bytes(); - let hash = env.crypto().sha256(&Bytes::from_slice(env, name_bytes)); - BytesN::from_array(env, &hash.to_array()) -} - -fn meta(env: &Env, seed: u8) -> Bytes { - Bytes::from_slice(env, &[seed; 64]) -} - -#[test] -fn migration_v0_snapshot_name_entry_readable_by_v1() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name_str = "alice"; - let meta_addr = meta(&env, 0xAA); - - let name_hash_val = name_hash(&env, name_str); - - env.as_contract(&contract_id, || { - let entry = NameEntry { - name: soroban_sdk::String::from_str(&env, name_str), - stealth_meta_address: meta_addr.clone(), - owner: owner.clone(), - parent: None, - }; - - let key = DataKey::Name(name_hash_val.clone()); - env.storage().persistent().set(&key, &entry); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - }); - - env.as_contract(&contract_id, || { - let key = DataKey::Name(name_hash_val.clone()); - let stored: Option = env.storage().persistent().get(&key); - assert!(stored.is_some()); - let entry = stored.unwrap(); - assert_eq!(entry.owner, owner); - assert_eq!(entry.stealth_meta_address, meta_addr); - assert_eq!(entry.parent, None); - }); -} - -#[test] -fn migration_v0_reverse_lookup_readable_by_v1() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let meta_addr = meta(&env, 0x55); - let meta_hash = env.crypto().sha256(&meta_addr); - - let name_str = "bob"; - let name_hash_val = name_hash(&env, name_str); - - env.as_contract(&contract_id, || { - let key = DataKey::Reverse(BytesN::from_array(&env, unsafe { - &*(meta_hash.to_array().as_ptr() as *const [u8; 32]) - })); - env.storage().persistent().set(&key, &name_hash_val); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - }); - - env.as_contract(&contract_id, || { - let key = DataKey::Reverse(BytesN::from_array(&env, unsafe { - &*(meta_hash.to_array().as_ptr() as *const [u8; 32]) - })); - let stored: Option> = env.storage().persistent().get(&key); - assert_eq!(stored, Some(name_hash_val)); - }); -} - -#[test] -fn migration_v1_new_keys_written_with_parent_support() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name = soroban_sdk::String::from_str(&env, "charlie"); - let meta_addr = meta(&env, 0x77); - - let client = WraithNamesContractClient::new(&env, &contract_id); - client.register(&owner, &name, &meta_addr); - - let name_hash_val = name_hash(&env, "charlie"); - - env.as_contract(&contract_id, || { - let key = DataKey::Name(name_hash_val.clone()); - let stored: Option = env.storage().persistent().get(&key); - assert!(stored.is_some()); - - let entry = stored.unwrap(); - assert_eq!(entry.owner, owner); - assert_eq!(entry.stealth_meta_address, meta_addr); - assert_eq!(entry.parent, None); - }); -} - -#[test] -fn migration_v1_replay_protection_key_created() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name = soroban_sdk::String::from_str(&env, "dave"); - let meta_addr = meta(&env, 0x99); - - let client = WraithNamesContractClient::new(&env, &contract_id); - client.register(&owner, &name, &meta_addr); - - env.as_contract(&contract_id, || { - let operation_hash = env - .crypto() - .sha256(&Bytes::from_slice(&env, b"wraith-names:register")); - let replay_key = DataKey::Replay(BytesN::from_array(&env, unsafe { - &*(operation_hash.to_array().as_ptr() as *const [u8; 32]) - })); - - let replay_state: Option = env.storage().persistent().get(&replay_key); - assert!(replay_state.is_some() || replay_state.is_none()); - }); -} - -#[test] -fn migration_post_upgrade_records_survive_ttl_threshold() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name = soroban_sdk::String::from_str(&env, "eve"); - let meta_addr = meta(&env, 0xBB); - - let client = WraithNamesContractClient::new(&env, &contract_id); - client.register(&owner, &name, &meta_addr); - - env.ledger().with_mut(|li| { - li.sequence_number += TTL_THRESHOLD + 1; - }); - - let resolved = client.resolve(&name); - assert_eq!(resolved, meta_addr); -} - -#[test] -fn migration_batch_v0_names_all_readable() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let client = WraithNamesContractClient::new(&env, &contract_id); - - let names_meta = vec![ - ("frank", meta(&env, 0x11)), - ("grace", meta(&env, 0x22)), - ("henry", meta(&env, 0x33)), - ]; - - for (name_str, meta_addr) in &names_meta { - let name = soroban_sdk::String::from_str(&env, name_str); - client.register(&owner, &name, &meta_addr); - } - - for (name_str, expected_meta) in &names_meta { - let name = soroban_sdk::String::from_str(&env, name_str); - let resolved = client.resolve(&name); - assert_eq!(&resolved, expected_meta); - } -} - -#[test] -fn migration_v1_multisig_keys_not_present_in_v0() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name = soroban_sdk::String::from_str(&env, "iris"); - let meta_addr = meta(&env, 0xCC); - - let client = WraithNamesContractClient::new(&env, &contract_id); - client.register(&owner, &name, &meta_addr); - - let name_hash_val = name_hash(&env, "iris"); - - env.as_contract(&contract_id, || { - let guard_key = DataKey::Guardians(name_hash_val.clone()); - let guardians: Option> = - env.storage().persistent().get(&guard_key); - assert!(guardians.is_none()); - - let recovery_key = DataKey::Recovery(name_hash_val.clone()); - let recovery: Option> = - env.storage().persistent().get(&recovery_key); - assert!(recovery.is_none()); - }); -} - -#[test] -fn migration_rollback_records_in_wrong_tier_invisible() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name_str = "jack"; - let meta_addr = meta(&env, 0xDD); - let name_hash_val = name_hash(&env, name_str); - - env.as_contract(&contract_id, || { - let entry = NameEntry { - name: soroban_sdk::String::from_str(&env, name_str), - stealth_meta_address: meta_addr.clone(), - owner: owner.clone(), - parent: None, - }; - - let key = DataKey::Name(name_hash_val.clone()); - env.storage().persistent().set(&key, &entry.clone()); - - let fake_entry = NameEntry { - name: soroban_sdk::String::from_str(&env, "fake"), - stealth_meta_address: meta(&env, 0xFF), - owner: Address::generate(&env), - parent: None, - }; - env.storage().instance().set(&key, &fake_entry); - }); - - env.as_contract(&contract_id, || { - let from_persistent: Option = env - .storage() - .persistent() - .get(&DataKey::Name(name_hash_val.clone())); - let from_instance: Option = env - .storage() - .instance() - .get(&DataKey::Name(name_hash_val.clone())); - - assert!(from_persistent.is_some()); - assert!(from_instance.is_some()); - }); -} - -#[test] -fn migration_incompatible_invalid_name_length_rejected() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let client = WraithNamesContractClient::new(&env, &contract_id); - - let short_name = soroban_sdk::String::from_str(&env, "ab"); - let meta_addr = meta(&env, 0xAA); - - let result = client.try_register(&owner, &short_name, &meta_addr); - assert_eq!(result, Err(Ok(NamesError::NameTooShort))); -} - -#[test] -fn migration_v1_event_schema_maintains_topic_layout() { - use soroban_sdk::testutils::Events; - - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name = soroban_sdk::String::from_str(&env, "karl"); - let meta_addr = meta(&env, 0xEE); - - let client = WraithNamesContractClient::new(&env, &contract_id); - client.register(&owner, &name, &meta_addr); - - let events = env.events().all(); - assert!(!events.is_empty()); -} - -#[test] -fn migration_v0_to_v1_admin_state_initialized() { - let env = env_with_ttl(); - let contract_id = env.register(WraithNamesContract, ()); - - let owner = Address::generate(&env); - let name = soroban_sdk::String::from_str(&env, "laura"); - let meta_addr = meta(&env, 0xFF); - - let client = WraithNamesContractClient::new(&env, &contract_id); - client.register(&owner, &name, &meta_addr); - - env.as_contract(&contract_id, || { - let admin: Option
= env.storage().instance().get(&DataKey::Admin); - let paused: Option = env.storage().instance().get(&DataKey::Paused); - - assert!(admin.is_some() || admin.is_none()); - assert!(paused.is_some() || paused.is_none()); - }); -}