Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions stellar/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions stellar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
28 changes: 28 additions & 0 deletions stellar/MIGRATION_TESTING.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions stellar/migration-fixtures/stealth-registry-v0/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
82 changes: 82 additions & 0 deletions stellar/migration-fixtures/stealth-registry-v0/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Bytes, RegistryError> {
let key = DataKey::MetaAddress(registrant, scheme_id);
env.storage()
.instance()
.get(&key)
.ok_or(RegistryError::NotRegistered)
}
}

12 changes: 12 additions & 0 deletions stellar/migration-fixtures/stealth-sender-v0/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
144 changes: 144 additions & 0 deletions stellar/migration-fixtures/stealth-sender-v0/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Address>,
ephemeral_pub_keys: Vec<BytesN<32>>,
metadatas: Vec<Bytes>,
amounts: Vec<i128>,
) -> 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(())
}
}
12 changes: 12 additions & 0 deletions stellar/migration-fixtures/wraith-names-v0/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading