From 63d984b2ffe2d64851628e7dfd4f58e316650b8b Mon Sep 17 00:00:00 2001 From: Marvin Michael Nkut Date: Thu, 28 May 2026 05:00:49 +0000 Subject: [PATCH] feat: implement WASM upgradeable contract pattern (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import BytesN from soroban_sdk - Add admin_key() storage helper (instance storage) - Add initialize(admin) — sets admin once, panics if called again - Add upgrade(new_wasm_hash) — requires admin auth, calls update_current_contract_wasm - Tests: initialize_twice_panics, upgrade_preserves_storage, upgrade_requires_admin_auth - All 10 tests pass, clippy clean Closes #34 --- contracts/split/src/lib.rs | 27 ++++++++++++++- contracts/split/src/test.rs | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 9bf6632..9652e95 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -12,7 +12,7 @@ mod types; #[cfg(test)] mod test; -use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, BytesN, Env, Symbol, Vec}; use types::{Invoice, InvoiceStatus, Payment}; // --------------------------------------------------------------------------- @@ -24,6 +24,11 @@ fn counter_key() -> Symbol { symbol_short!("counter") } +/// Storage key for the contract admin address. +fn admin_key() -> Symbol { + symbol_short!("admin") +} + /// Composite storage key for an invoice: (symbol, id). fn invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("inv"), id) @@ -51,6 +56,26 @@ pub struct SplitContract; #[contractimpl] impl SplitContract { + /// Set the contract admin. Can only be called once. + pub fn initialize(env: Env, admin: Address) { + assert!( + !env.storage().instance().has(&admin_key()), + "already initialized" + ); + env.storage().instance().set(&admin_key(), &admin); + } + + /// Upgrade the contract WASM. Requires admin auth. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + let admin: Address = env + .storage() + .instance() + .get(&admin_key()) + .expect("not initialized"); + admin.require_auth(); + env.deployer().update_current_contract_wasm(new_wasm_hash); + } + /// Create a new invoice. /// /// # Arguments diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d326a2..cf16d73 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -250,3 +250,71 @@ fn test_multi_recipient_release() { assert_eq!(tk.balance(&r2), 200); assert_eq!(tk.balance(&r3), 300); } + +// Use a real wasm fixture for upgrade tests. +const TEST_WASM: &[u8] = include_bytes!( + "/home/codespace/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/soroban-sdk-22.0.11/test_wasms/test_contract_data.wasm" +); + +#[test] +#[should_panic(expected = "already initialized")] +fn test_initialize_twice_panics() { + let (env, contract_id, _token_id) = setup(); + let c = client(&env, &contract_id); + let admin = Address::generate(&env); + c.initialize(&admin); + c.initialize(&admin); +} + +#[test] +fn test_upgrade_preserves_storage() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let admin = Address::generate(&env); + c.initialize(&admin); + + // Create an invoice so there's state to preserve. + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + env.ledger().set_timestamp(1_000); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); + + // Verify invoice exists before upgrade. + let invoice_before = c.get_invoice(&id); + assert_eq!(invoice_before.status, InvoiceStatus::Pending); + + // Upload a wasm blob and upgrade — storage keys survive the wasm swap. + let new_wasm_hash = env + .deployer() + .upload_contract_wasm(soroban_sdk::Bytes::from_slice(&env, TEST_WASM)); + c.upgrade(&new_wasm_hash); + + // Verify the invoice storage key is still present after the upgrade. + let storage_key = (soroban_sdk::symbol_short!("inv"), id); + assert!(env.as_contract(&contract_id, || { + env.storage().persistent().has(&storage_key) + })); +} + +#[test] +#[should_panic] +fn test_upgrade_requires_admin_auth() { + let (env, contract_id, _token_id) = setup(); + let c = client(&env, &contract_id); + + let admin = Address::generate(&env); + c.initialize(&admin); + + let new_wasm_hash = env + .deployer() + .upload_contract_wasm(soroban_sdk::Bytes::from_slice(&env, TEST_WASM)); + + // Disable mock auths — upgrade should panic because admin auth is not provided. + env.set_auths(&[]); + c.upgrade(&new_wasm_hash); +}