Skip to content

Commit 514233d

Browse files
committed
feat: add v2 admin rotation
1 parent f962efb commit 514233d

4 files changed

Lines changed: 782 additions & 1 deletion

File tree

contracts/tholos-v2/src/lib.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ pub struct PauseUpdated {
211211
pub paused: bool,
212212
}
213213

214+
#[contractevent]
215+
pub struct AdminUpdated {
216+
pub old_admin: Address,
217+
pub new_admin: Address,
218+
}
219+
214220
#[contractevent]
215221
pub struct RoundCancelled {
216222
#[topic]
@@ -750,6 +756,31 @@ impl TholosV2 {
750756
.ok_or(Error::NotInitialized)
751757
}
752758

759+
/// Replaces the deployment admin. Only the current admin may authorize
760+
/// the change. The old admin loses authority as soon as this call
761+
/// succeeds. Fails with `NotInitialized` before `initialize` and emits
762+
/// `AdminUpdated` on success.
763+
pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> {
764+
let old_admin: Address = env
765+
.storage()
766+
.instance()
767+
.get(&DataKey::Admin)
768+
.ok_or(Error::NotInitialized)?;
769+
old_admin.require_auth();
770+
771+
env.storage().instance().set(&DataKey::Admin, &new_admin);
772+
env.storage()
773+
.instance()
774+
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
775+
AdminUpdated {
776+
old_admin,
777+
new_admin,
778+
}
779+
.publish(&env);
780+
781+
Ok(())
782+
}
783+
753784
/// Blocks or unblocks new `assert_outcome` calls. Only callable by the
754785
/// admin set at `initialize`. Does not affect any already-active
755786
/// round: registration, reveal, `resolve_outcome`, `settle`, and

contracts/tholos-v2/src/test.rs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
use super::*;
44
use soroban_sdk::testutils::storage::Persistent as _;
5-
use soroban_sdk::testutils::{Address as _, Ledger};
5+
use soroban_sdk::testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke};
6+
use soroban_sdk::IntoVal;
67

78
const DEFAULT_BOND: i128 = 100;
89
const DEFAULT_CHALLENGE_WINDOW: u64 = 3600;
@@ -2696,6 +2697,98 @@ fn test_set_paused_v2_blocks_new_assertions() {
26962697
f.client.assert_outcome(&asserter, &true);
26972698
}
26982699

2700+
#[test]
2701+
fn test_admin_rotation_updates_authority() {
2702+
let env = Env::default();
2703+
let token_id = setup(&env);
2704+
let contract_id = env.register(TholosV2, ());
2705+
let client = TholosV2Client::new(&env, &contract_id);
2706+
let old_admin = Address::generate(&env);
2707+
let new_admin = Address::generate(&env);
2708+
let arbitrary = Address::generate(&env);
2709+
2710+
env.mock_auths(&[MockAuth {
2711+
address: &old_admin,
2712+
invoke: &MockAuthInvoke {
2713+
contract: &contract_id,
2714+
fn_name: "initialize",
2715+
args: (
2716+
old_admin.clone(),
2717+
token_id.clone(),
2718+
DEFAULT_BOND,
2719+
DEFAULT_CHALLENGE_WINDOW,
2720+
DEFAULT_FINALIZE_REWARD_BPS,
2721+
DEFAULT_REGISTRATION_SECS,
2722+
DEFAULT_ANTI_SNIPE_EXT_SECS,
2723+
DEFAULT_ANTI_SNIPE_HARD_MAX_SECS,
2724+
DEFAULT_REVEAL_SECS,
2725+
DEFAULT_MAX_POSITION,
2726+
DEFAULT_MAX_TOTAL_WEIGHT,
2727+
)
2728+
.into_val(&env),
2729+
sub_invokes: &[],
2730+
},
2731+
}]);
2732+
init(
2733+
&client,
2734+
&old_admin,
2735+
&token_id,
2736+
DEFAULT_BOND,
2737+
DEFAULT_CHALLENGE_WINDOW,
2738+
DEFAULT_FINALIZE_REWARD_BPS,
2739+
)
2740+
.unwrap()
2741+
.unwrap();
2742+
2743+
// An arbitrary address cannot authorize a rotation: set_admin always
2744+
// requires the admin currently stored by the contract.
2745+
env.mock_auths(&[MockAuth {
2746+
address: &arbitrary,
2747+
invoke: &MockAuthInvoke {
2748+
contract: &contract_id,
2749+
fn_name: "set_admin",
2750+
args: (new_admin.clone(),).into_val(&env),
2751+
sub_invokes: &[],
2752+
},
2753+
}]);
2754+
assert!(client.try_set_admin(&new_admin).is_err());
2755+
2756+
env.mock_auths(&[MockAuth {
2757+
address: &old_admin,
2758+
invoke: &MockAuthInvoke {
2759+
contract: &contract_id,
2760+
fn_name: "set_admin",
2761+
args: (new_admin.clone(),).into_val(&env),
2762+
sub_invokes: &[],
2763+
},
2764+
}]);
2765+
client.set_admin(&new_admin);
2766+
2767+
// Rotation is immediate: the previous admin can no longer use an
2768+
// admin-only entrypoint, while the new admin can.
2769+
env.mock_auths(&[MockAuth {
2770+
address: &old_admin,
2771+
invoke: &MockAuthInvoke {
2772+
contract: &contract_id,
2773+
fn_name: "set_paused_v2",
2774+
args: (true,).into_val(&env),
2775+
sub_invokes: &[],
2776+
},
2777+
}]);
2778+
assert!(client.try_set_paused_v2(&true).is_err());
2779+
2780+
env.mock_auths(&[MockAuth {
2781+
address: &new_admin,
2782+
invoke: &MockAuthInvoke {
2783+
contract: &contract_id,
2784+
fn_name: "set_paused_v2",
2785+
args: (true,).into_val(&env),
2786+
sub_invokes: &[],
2787+
},
2788+
}]);
2789+
client.set_paused_v2(&true);
2790+
}
2791+
26992792
#[test]
27002793
fn test_set_paused_v2_does_not_block_existing_round() {
27012794
// The narrower v2 pause only ever gates assert_outcome: an

0 commit comments

Comments
 (0)