Skip to content
Merged
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
44 changes: 41 additions & 3 deletions contracts/tholos-v2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub struct PauseUpdated {
pub paused: bool,
}

#[contractevent]
pub struct AdminUpdated {
pub old_admin: Address,
pub new_admin: Address,
}

#[contractevent]
pub struct RoundCancelled {
#[topic]
Expand Down Expand Up @@ -733,9 +739,7 @@ impl TholosV2 {
env.storage().instance().set(&DataKey::Policy, &policy);
env.storage().instance().set(&DataKey::NextId, &0u64);
env.storage().instance().set(&DataKey::Paused, &false);
env.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
Self::touch_instance_ttl(&env);

Ok(())
}
Expand All @@ -750,6 +754,29 @@ impl TholosV2 {
.ok_or(Error::NotInitialized)
}

/// Replaces the deployment admin. Only the current admin may authorize
/// the change. The old admin loses authority as soon as this call
/// succeeds. Fails with `NotInitialized` before `initialize` and emits
/// `AdminUpdated` on success.
pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> {
let old_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
old_admin.require_auth();

env.storage().instance().set(&DataKey::Admin, &new_admin);
Self::touch_instance_ttl(&env);
AdminUpdated {
old_admin,
new_admin,
}
.publish(&env);

Ok(())
}

/// Blocks or unblocks new `assert_outcome` calls. Only callable by the
/// admin set at `initialize`. Does not affect any already-active
/// round: registration, reveal, `resolve_outcome`, `settle`, and
Expand All @@ -765,6 +792,7 @@ impl TholosV2 {
.ok_or(Error::NotInitialized)?;
admin.require_auth();

Self::touch_instance_ttl(&env);
env.storage().instance().set(&DataKey::Paused, &paused);
PauseUpdated { paused }.publish(&env);

Expand All @@ -780,6 +808,16 @@ impl TholosV2 {
.ok_or(Error::AssertionNotFound)
}

/// Renews instance storage after a state-changing call that uses the
/// deployment-wide instance entries. Keeping this in one helper prevents
/// an admin or pause operation from leaving those entries to expire while
/// the contract is still in use.
fn touch_instance_ttl(env: &Env) {
env.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
}

/// TTL bump `(threshold, amount)`, in ledgers, sized to cover one
/// dispute's full worst-case active-phase horizon (registration through
/// reveal, per this specific assertion's own pinned policy) plus
Expand Down
121 changes: 119 additions & 2 deletions contracts/tholos-v2/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#![cfg(test)]

use super::*;
use soroban_sdk::testutils::storage::Persistent as _;
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::testutils::storage::{Instance as _, Persistent as _};
use soroban_sdk::testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke};
use soroban_sdk::IntoVal;

const DEFAULT_BOND: i128 = 100;
const DEFAULT_CHALLENGE_WINDOW: u64 = 3600;
Expand Down Expand Up @@ -2863,6 +2864,122 @@ fn test_set_paused_v2_blocks_new_assertions() {
f.client.assert_outcome(&asserter, &true);
}

#[test]
fn test_admin_state_changes_renew_instance_storage_ttl() {
let f = Fixture::new();

let instance_ttl = || {
f.env
.as_contract(&f.client.address, || f.env.storage().instance().get_ttl())
};

assert_eq!(instance_ttl(), INSTANCE_BUMP_AMOUNT);

f.env
.ledger()
.with_mut(|l| l.sequence_number += INSTANCE_BUMP_AMOUNT - 10);
f.client.set_paused_v2(&true);
assert_eq!(instance_ttl(), INSTANCE_BUMP_AMOUNT);

f.env
.ledger()
.with_mut(|l| l.sequence_number += INSTANCE_BUMP_AMOUNT - 10);
f.client.set_admin(&f.generate());
assert_eq!(instance_ttl(), INSTANCE_BUMP_AMOUNT);
}

#[test]
fn test_admin_rotation_updates_authority() {
let env = Env::default();
let token_id = setup(&env);
let contract_id = env.register(TholosV2, ());
let client = TholosV2Client::new(&env, &contract_id);
let old_admin = Address::generate(&env);
let new_admin = Address::generate(&env);
let arbitrary = Address::generate(&env);

env.mock_auths(&[MockAuth {
address: &old_admin,
invoke: &MockAuthInvoke {
contract: &contract_id,
fn_name: "initialize",
args: (
old_admin.clone(),
token_id.clone(),
DEFAULT_BOND,
DEFAULT_CHALLENGE_WINDOW,
DEFAULT_FINALIZE_REWARD_BPS,
DEFAULT_REGISTRATION_SECS,
DEFAULT_ANTI_SNIPE_EXT_SECS,
DEFAULT_ANTI_SNIPE_HARD_MAX_SECS,
DEFAULT_REVEAL_SECS,
DEFAULT_MAX_POSITION,
DEFAULT_MAX_TOTAL_WEIGHT,
)
.into_val(&env),
sub_invokes: &[],
},
}]);
init(
&client,
&old_admin,
&token_id,
DEFAULT_BOND,
DEFAULT_CHALLENGE_WINDOW,
DEFAULT_FINALIZE_REWARD_BPS,
)
.unwrap()
.unwrap();

// An arbitrary address cannot authorize a rotation: set_admin always
// requires the admin currently stored by the contract.
env.mock_auths(&[MockAuth {
address: &arbitrary,
invoke: &MockAuthInvoke {
contract: &contract_id,
fn_name: "set_admin",
args: (new_admin.clone(),).into_val(&env),
sub_invokes: &[],
},
}]);
assert!(client.try_set_admin(&new_admin).is_err());

env.mock_auths(&[MockAuth {
address: &old_admin,
invoke: &MockAuthInvoke {
contract: &contract_id,
fn_name: "set_admin",
args: (new_admin.clone(),).into_val(&env),
sub_invokes: &[],
},
}]);
client.set_admin(&new_admin);

// Rotation is immediate: the previous admin can no longer use an
// admin-only entrypoint, while the new admin can.
env.mock_auths(&[MockAuth {
address: &old_admin,
invoke: &MockAuthInvoke {
contract: &contract_id,
fn_name: "set_paused_v2",
args: (true,).into_val(&env),
sub_invokes: &[],
},
}]);
assert!(client.try_set_paused_v2(&true).is_err());

env.mock_auths(&[MockAuth {
address: &new_admin,
invoke: &MockAuthInvoke {
contract: &contract_id,
fn_name: "set_paused_v2",
args: (true,).into_val(&env),
sub_invokes: &[],
},
}]);
client.set_paused_v2(&true);
}

#[test]
fn test_set_paused_v2_does_not_block_existing_round() {
// The narrower v2 pause only ever gates assert_outcome: an
Expand Down
Loading
Loading