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
81 changes: 81 additions & 0 deletions contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ pub struct BondAmountUpdated {
pub bond_amount: i128,
}

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

#[contractevent]
pub struct AdminRotationProposed {
pub new_admin: Address,
pub proposed_by: Address,
}

#[contractevent]
pub struct RotationProposed {
pub old_resolver: Address,
Expand Down Expand Up @@ -92,6 +104,14 @@ pub struct RotationProposal {
pub no: Vec<Address>,
}

/// A pending deployment-admin rotation. The current admin proposes a target,
/// then that target must authorize `accept_admin` before authority changes.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdminRotationProposal {
pub new_admin: Address,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Status {
Expand Down Expand Up @@ -149,6 +169,7 @@ pub enum DataKey {
/// full bond is returned to the asserter (original behavior).
FinalizeRewardBps,
RotationProposal,
AdminRotationProposal,
}

#[contracterror]
Expand Down Expand Up @@ -182,6 +203,7 @@ pub enum Error {
/// slot without any economic risk (they receive both bonds back regardless
/// of the resolver vote), nullifying the bond-forfeiture deterrent.
SelfDispute = 22,
NoAdminRotationProposal = 23,
}

const DAY_IN_LEDGERS: u32 = 17280;
Expand Down Expand Up @@ -307,6 +329,65 @@ impl Tholos {
Ok(())
}

/// Proposes a deployment-admin rotation. Only the current admin may
/// authorize the proposal; authority remains unchanged until the proposed
/// address calls `accept_admin`.
pub fn propose_admin(env: Env, new_admin: Address) -> Result<(), Error> {
let current_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
current_admin.require_auth();
Self::touch_instance_ttl(&env);

env.storage().instance().set(
&DataKey::AdminRotationProposal,
&AdminRotationProposal {
new_admin: new_admin.clone(),
},
);
AdminRotationProposed {
new_admin,
proposed_by: current_admin,
}
.publish(&env);

Ok(())
}

/// Completes the pending deployment-admin rotation. The proposed address
/// must authorize this call, so a current admin cannot complete a rotation
/// without the new admin's consent. Fails when no proposal exists.
pub fn accept_admin(env: Env) -> Result<(), Error> {
let proposal: AdminRotationProposal = env
.storage()
.instance()
.get(&DataKey::AdminRotationProposal)
.ok_or(Error::NoAdminRotationProposal)?;
proposal.new_admin.require_auth();
let old_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
Self::touch_instance_ttl(&env);

env.storage()
.instance()
.set(&DataKey::Admin, &proposal.new_admin);
env.storage()
.instance()
.remove(&DataKey::AdminRotationProposal);
AdminUpdated {
old_admin,
new_admin: proposal.new_admin,
}
.publish(&env);

Ok(())
}

/// Replaces the resolver committee. Only callable by the admin set at
/// initialization. `new_resolvers` must have an odd length so a simple
/// majority vote can never tie. Callable even while paused, so a
Expand Down
112 changes: 111 additions & 1 deletion contracts/tholos/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

const DEFAULT_BOND: i128 = 100;
const DEFAULT_WINDOW: u64 = 3600;
Expand Down Expand Up @@ -644,6 +645,115 @@ fn test_admin_can_update_resolvers() {
assert_eq!(f.token.balance(&disputer), 1_100);
}

#[test]
fn test_admin_rotation_updates_authority() {
let env = Env::default();
let (token_id, resolvers) = setup(&env);
let contract_id = env.register(Tholos, ());
let client = TholosClient::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_WINDOW,
resolvers.clone(),
0u32,
)
.into_val(&env),
sub_invokes: &[],
},
}]);
client.initialize(
&old_admin,
&token_id,
&DEFAULT_BOND,
&DEFAULT_WINDOW,
&resolvers,
&0u32,
);

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

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

// The old admin cannot complete the proposal because the new admin must
// explicitly authorize acceptance.
env.mock_auths(&[MockAuth {
address: &old_admin,
invoke: &MockAuthInvoke {
contract: &contract_id,
fn_name: "accept_admin",
args: ().into_val(&env),
sub_invokes: &[],
},
}]);
assert!(client.try_accept_admin().is_err());

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

// After acceptance 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",
args: (true,).into_val(&env),
sub_invokes: &[],
},
}]);
assert!(client.try_set_paused(&true).is_err());

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

#[test]
fn test_resolvers_updated_mid_dispute_do_not_affect_it() {
let f = Fixture::new();
Expand Down
Loading
Loading