From 55062a0fff70c2045e42fa79865b8b3d672e7662 Mon Sep 17 00:00:00 2001 From: Arowolokehinde Date: Sun, 29 Mar 2026 08:23:16 +0100 Subject: [PATCH 1/2] feat(contract): add admin role and rotation --- contracts/chronopay/src/lib.rs | 38 ++++++++++++++ contracts/chronopay/src/test.rs | 89 +++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/contracts/chronopay/src/lib.rs b/contracts/chronopay/src/lib.rs index 9b86182..1ff3421 100644 --- a/contracts/chronopay/src/lib.rs +++ b/contracts/chronopay/src/lib.rs @@ -18,6 +18,8 @@ pub enum DataKey { SlotSeq, /// Contract administrator address. Admin, + /// Proposed new contract administrator. + ProposedAdmin, /// Owner address of a specific token (keyed by slot id). TokenOwner(u32), /// Status of a specific token (keyed by slot id). @@ -57,6 +59,42 @@ impl ChronoPayContract { env.storage().instance().set(&DataKey::Admin, &admin); } + // ----------------------------------------------------------------------- + // Admin rotation + // ----------------------------------------------------------------------- + + /// Propose a new admin. Only the current admin can call this. + /// This is the first step of a two-step rotation process. + pub fn propose_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + + if admin == new_admin { + panic!("already admin"); + } + + env.storage().instance().set(&DataKey::ProposedAdmin, &new_admin); + } + + /// Accept the proposed admin role. Only the proposed new admin can call this. + /// This completes the two-step rotation process. + pub fn accept_admin(env: Env, new_admin: Address) { + new_admin.require_auth(); + + let proposed: Address = env + .storage() + .instance() + .get(&DataKey::ProposedAdmin) + .expect("no proposed admin"); + + if new_admin != proposed { + panic!("caller not proposed admin"); + } + + // Complete the transfer + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().remove(&DataKey::ProposedAdmin); + } + // ----------------------------------------------------------------------- // Slot management // ----------------------------------------------------------------------- diff --git a/contracts/chronopay/src/test.rs b/contracts/chronopay/src/test.rs index cc6fd27..772d55c 100644 --- a/contracts/chronopay/src/test.rs +++ b/contracts/chronopay/src/test.rs @@ -6,6 +6,8 @@ //! | Entry point | Positive path | Unauth caller | Wrong role | Bad state | //! |--------------------|:---:|:---:|:---:|:---:| //! | `initialize` | ✓ | ✓ | — | ✓ (re-init) | +//! | `propose_admin` | ✓ | ✓ | ✓ (non-admin) | ✓ (same admin) | +//! | `accept_admin` | ✓ | ✓ | ✓ (wrong admin)| ✓ (no proposal)| //! | `create_time_slot` | ✓ | ✓ | — | ✓ (bad range) | //! | `mint_time_token` | ✓ | ✓ | ✓ (non-admin) | — | //! | `buy_time_token` | ✓ | ✓ | — | ✓ (unminted/sold) | @@ -109,7 +111,86 @@ fn test_initialize_rejects_same_admin_reinit() { } // =========================================================================== -// 3. `create_time_slot` +// 3. Admin rotation +// =========================================================================== + +#[test] +fn test_propose_admin_success() { + let (env, client, admin) = setup_with_admin(); + let new_admin = Address::generate(&env); + client.propose_admin(&admin, &new_admin); +} + +#[test] +#[should_panic(expected = "caller is not admin")] +fn test_propose_admin_rejects_non_admin() { + let (env, client, _admin) = setup_with_admin(); + let impostor = Address::generate(&env); + let new_admin = Address::generate(&env); + client.propose_admin(&impostor, &new_admin); +} + +#[test] +#[should_panic(expected = "already admin")] +fn test_propose_admin_rejects_same_admin() { + let (_env, client, admin) = setup_with_admin(); + client.propose_admin(&admin, &admin); +} + +#[test] +#[should_panic(expected = "HostError: Error(Auth")] +fn test_propose_admin_rejects_without_auth_signature() { + let env = Env::default(); + let contract_id = env.register(ChronoPayContract, ()); + let client = ChronoPayContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); +} + +#[test] +fn test_accept_admin_success() { + let (env, client, admin) = setup_with_admin(); + let new_admin = Address::generate(&env); + + client.propose_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + // Test the new admin can propose a transfer + let another_admin = Address::generate(&env); + client.propose_admin(&new_admin, &another_admin); +} + +#[test] +#[should_panic(expected = "caller not proposed admin")] +fn test_accept_admin_rejects_non_proposed() { + let (env, client, admin) = setup_with_admin(); + let new_admin = Address::generate(&env); + let interloper = Address::generate(&env); + + client.propose_admin(&admin, &new_admin); + client.accept_admin(&interloper); +} + +#[test] +#[should_panic(expected = "no proposed admin")] +fn test_accept_admin_rejects_before_proposal() { + let (env, client, _admin) = setup_with_admin(); + let new_admin = Address::generate(&env); + client.accept_admin(&new_admin); +} + +#[test] +#[should_panic(expected = "HostError: Error(Auth")] +fn test_accept_admin_rejects_without_auth_signature() { + let env = Env::default(); + let contract_id = env.register(ChronoPayContract, ()); + let client = ChronoPayContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); +} + +// =========================================================================== +// 4. `create_time_slot` // =========================================================================== #[test] @@ -157,7 +238,7 @@ fn test_create_time_slot_rejects_equal_start_end() { } // =========================================================================== -// 4. `mint_time_token` +// 5. `mint_time_token` // =========================================================================== #[test] @@ -203,7 +284,7 @@ fn test_mint_time_token_rejects_before_init() { } // =========================================================================== -// 5. `buy_time_token` +// 6. `buy_time_token` // =========================================================================== #[test] @@ -257,7 +338,7 @@ fn test_buy_time_token_rejects_already_sold() { } // =========================================================================== -// 6. `redeem_time_token` +// 7. `redeem_time_token` // =========================================================================== #[test] From d7e2f7dfa5751293e17113e91d194dd05f37d490 Mon Sep 17 00:00:00 2001 From: Arowolokehinde Date: Sun, 29 Mar 2026 08:27:42 +0100 Subject: [PATCH 2/2] style: fix cargo fmt issues --- contracts/chronopay/src/lib.rs | 4 +++- contracts/chronopay/src/test.rs | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/chronopay/src/lib.rs b/contracts/chronopay/src/lib.rs index 1ff3421..d4a076f 100644 --- a/contracts/chronopay/src/lib.rs +++ b/contracts/chronopay/src/lib.rs @@ -72,7 +72,9 @@ impl ChronoPayContract { panic!("already admin"); } - env.storage().instance().set(&DataKey::ProposedAdmin, &new_admin); + env.storage() + .instance() + .set(&DataKey::ProposedAdmin, &new_admin); } /// Accept the proposed admin role. Only the proposed new admin can call this. diff --git a/contracts/chronopay/src/test.rs b/contracts/chronopay/src/test.rs index 772d55c..52220ad 100644 --- a/contracts/chronopay/src/test.rs +++ b/contracts/chronopay/src/test.rs @@ -151,10 +151,10 @@ fn test_propose_admin_rejects_without_auth_signature() { fn test_accept_admin_success() { let (env, client, admin) = setup_with_admin(); let new_admin = Address::generate(&env); - + client.propose_admin(&admin, &new_admin); client.accept_admin(&new_admin); - + // Test the new admin can propose a transfer let another_admin = Address::generate(&env); client.propose_admin(&new_admin, &another_admin); @@ -166,7 +166,7 @@ fn test_accept_admin_rejects_non_proposed() { let (env, client, admin) = setup_with_admin(); let new_admin = Address::generate(&env); let interloper = Address::generate(&env); - + client.propose_admin(&admin, &new_admin); client.accept_admin(&interloper); }