diff --git a/stellar/Cargo.lock b/stellar/Cargo.lock index 2874029..5e78db1 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 5719fb0..9a1ecb8 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 0000000..86ad10f --- /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 0000000..bc75d0a --- /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 0000000..41249de --- /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 0000000..de57b7f --- /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 0000000..e895373 --- /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 0000000..dd80335 --- /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 0000000..eb5a6d2 --- /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/upgrade-migration-tests/Cargo.toml b/stellar/upgrade-migration-tests/Cargo.toml new file mode 100644 index 0000000..d3757d6 --- /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 0000000..4a394f6 --- /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 0000000..28b89e7 --- /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 0000000..c3b6e8c --- /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 0000000..b36e733 --- /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 0000000..8715860 --- /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); +}