Skip to content
Open
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
111 changes: 102 additions & 9 deletions contracts/dao-governance-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ pub enum DataKey {
/// `lock_tokens` and `withdraw`). Used as the denominator for the
/// proportional quorum so it never requires iterating over lockers.
TotalLocked,
Paused,
}

pub const CONTRACT_VERSION: u32 = 1;
Expand Down Expand Up @@ -189,6 +190,7 @@ impl DaoGovernanceContract {
.set(&DataKey::Version, &CONTRACT_VERSION);
env.storage().instance().set(&DataKey::ProposalCount, &0u64);
env.storage().instance().set(&DataKey::TotalLocked, &0i128);
env.storage().instance().set(&DataKey::Paused, &false);
env.storage()
.instance()
.extend_ttl(MIN_VOTING_WINDOW, MAX_LOCK_LEDGERS);
Expand Down Expand Up @@ -219,6 +221,7 @@ impl DaoGovernanceContract {

pub fn lock_tokens(env: Env, voter: Address, amount: i128, lock_duration_ledgers: u32) {
voter.require_auth();
Self::require_not_paused(&env);
if amount <= 0 {
panic!("amount must be positive");
}
Expand Down Expand Up @@ -293,6 +296,7 @@ impl DaoGovernanceContract {

pub fn extend_lock(env: Env, voter: Address, new_unlock_ledger: u32) {
voter.require_auth();
Self::require_not_paused(&env);
let lock_key = DataKey::Lock(voter.clone());
if !env.storage().persistent().has(&lock_key) {
panic!("no active lock");
Expand Down Expand Up @@ -340,6 +344,7 @@ impl DaoGovernanceContract {

pub fn withdraw(env: Env, voter: Address) {
voter.require_auth();
Self::require_not_paused(&env);
let lock_key = DataKey::Lock(voter.clone());
if !env.storage().persistent().has(&lock_key) {
panic!("no lock found");
Expand Down Expand Up @@ -471,6 +476,52 @@ impl DaoGovernanceContract {
// Consequently, `dao_admin` has the authority to unilaterally veto a passed
// proposal by removing its target from the allowlist before execution occurs.

// ─── Emergency pause ────────────────────────────────────────────────────

pub fn pause(env: Env, caller: Address) {
caller.require_auth();
let config: Config = env
.storage()
.instance()
.get(&DataKey::Config)
.expect("Not initialized");
if config.dao_admin != caller {
panic!("Only admin can pause");
}
env.storage().instance().set(&DataKey::Paused, &true);
}

pub fn unpause(env: Env, caller: Address) {
caller.require_auth();
let config: Config = env
.storage()
.instance()
.get(&DataKey::Config)
.expect("Not initialized");
if config.dao_admin != caller {
panic!("Only admin can unpause");
}
env.storage().instance().set(&DataKey::Paused, &false);
}

pub fn is_paused(env: Env) -> bool {
env.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false)
}

fn require_not_paused(env: &Env) {
let paused: bool = env
.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false);
if paused {
panic!("Contract is paused");
}
}

/// Adds a `(target_contract, function)` pair to the execution allowlist.
///
/// # Access Control
Expand Down Expand Up @@ -559,6 +610,7 @@ impl DaoGovernanceContract {
calldata: Bytes,
) -> u64 {
proposer.require_auth();
Self::require_not_paused(&env);
let current = env.ledger().sequence();
let power = Self::get_voting_power(env.clone(), proposer.clone(), current);
if power <= 0 {
Expand Down Expand Up @@ -792,6 +844,7 @@ impl DaoGovernanceContract {
/// time. If `dao_admin` removed the target entry mid-flight (during discussion,
/// voting, or timelock), execution panics with `"target/function not allowlisted"`.
pub fn execute_proposal(env: Env, proposal_id: u64) {
Self::require_not_paused(&env);
let key = DataKey::Proposal(proposal_id);
let proposal: Proposal = env
.storage()
Expand Down Expand Up @@ -2671,23 +2724,63 @@ mod tests {
assert_eq!(client.get_proposal(&pid).stage, ProposalStage::Executed);
}

// ─── Pause / emergency-stop tests ──────────────────────────────────────

#[test]
fn test_pause_and_unpause() {
let env = Env::default();
env.mock_all_auths();
let (_cid, cfg, client) = deploy(&env);
assert!(!client.is_paused());
client.pause(&cfg.dao_admin);
assert!(client.is_paused());
client.unpause(&cfg.dao_admin);
assert!(!client.is_paused());
}

#[test]
fn test_version_exposed_and_default_v1() {
#[should_panic(expected = "Only admin can pause")]
fn test_pause_non_admin_fails() {
let env = Env::default();
env.mock_all_auths();
let (_cid, _cfg, client) = deploy(&env);
assert_eq!(client.get_version(), 1);
assert_eq!(client.version(), 1);
let rando = Address::generate(&env);
client.pause(&rando);
}

#[test]
fn test_storage_lifetimes_and_version_key() {
#[should_panic(expected = "Contract is paused")]
fn test_lock_tokens_rejected_when_paused() {
let env = Env::default();
env.mock_all_auths();
let (cid, _cfg, client) = deploy(&env);
assert_eq!(client.get_version(), 1);
let (_cid, cfg, client) = deploy(&env);
client.pause(&cfg.dao_admin);
let voter = Address::generate(&env);
let token = StellarAssetClient::new(&env, &cfg.gp_token);
token.mint(&voter, &1_000_000);
client.lock_tokens(&voter, &1_000_000, &MIN_LOCK_LEDGERS);
}

env.as_contract(&cid, || {
assert!(env.storage().instance().has(&DataKey::Version));
});
#[test]
#[should_panic(expected = "Contract is paused")]
fn test_create_proposal_rejected_when_paused() {
let env = Env::default();
env.mock_all_auths();
let (_cid, cfg, client) = deploy(&env);
let proposer = Address::generate(&env);
let token = StellarAssetClient::new(&env, &cfg.gp_token);
token.mint(&proposer, &1_000_000);
client.lock_tokens(&proposer, &1_000_000, &MIN_LOCK_LEDGERS);
client.pause(&cfg.dao_admin);
let target = Address::generate(&env);
let function = Symbol::new(&env, "do_thing");
client.create_proposal(
&proposer,
&soroban_sdk::String::from_str(&env, "Title"),
&soroban_sdk::String::from_str(&env, "Desc"),
&target,
&function,
&soroban_sdk::Bytes::new(&env),
);
}
}
113 changes: 84 additions & 29 deletions contracts/escrow-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub enum DataKey {
JobCount,
Job(String),
AllowedToken(Address),
Paused,
}

pub const CONTRACT_VERSION: u32 = 1;
Expand All @@ -101,23 +102,53 @@ impl EscrowContract {
panic!("Contract already initialized");
}
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage()
env.storage().instance().set(&DataKey::Paused, &false);
}

/// Emergency pause — blocks all fund-moving operations.
pub fn pause(env: Env, admin: Address) {
admin.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Not initialized");
if stored_admin != admin {
panic!("Only admin can pause");
}
env.storage().instance().set(&DataKey::Paused, &true);
}

/// Lift the emergency pause.
pub fn unpause(env: Env, admin: Address) {
admin.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.set(&DataKey::Version, &CONTRACT_VERSION);
env.storage().instance().set(&DataKey::JobCount, &0u32);
.get(&DataKey::Admin)
.expect("Not initialized");
if stored_admin != admin {
panic!("Only admin can unpause");
}
env.storage().instance().set(&DataKey::Paused, &false);
}

/// Exposes contract schema version.
pub fn get_version(env: Env) -> u32 {
pub fn is_paused(env: Env) -> bool {
env.storage()
.instance()
.get(&DataKey::Version)
.unwrap_or(1u32)
.get(&DataKey::Paused)
.unwrap_or(false)
}

/// Alias for get_version.
pub fn version(env: Env) -> u32 {
Self::get_version(env)
fn require_not_paused(env: &Env) {
let paused: bool = env
.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false);
if paused {
panic!("Contract is paused");
}
}

/// Allows a specific token to be used for jobs.
Expand Down Expand Up @@ -166,6 +197,7 @@ impl EscrowContract {
expiry_ledger: u32,
) {
client.require_auth();
Self::require_not_paused(&env);
if amount <= 0 {
panic!("Amount must be positive");
}
Expand Down Expand Up @@ -212,6 +244,7 @@ impl EscrowContract {
/// Client authorizes full release of remaining locked funds to the freelancer.
pub fn release_escrow(env: Env, client: Address, job_id: String) {
client.require_auth();
Self::require_not_paused(&env);
let mut job: Job = env
.storage()
.instance()
Expand Down Expand Up @@ -309,6 +342,7 @@ impl EscrowContract {
/// Admin resolves a disputed job: releases remaining funds to freelancer or refunds remaining funds to client.
pub fn resolve_dispute(env: Env, admin: Address, job_id: String, release_to_freelancer: bool) {
admin.require_auth();
Self::require_not_paused(&env);
let stored_admin: Address = env
.storage()
.instance()
Expand Down Expand Up @@ -364,6 +398,7 @@ impl EscrowContract {
/// CEI ordering: all state writes happen before both token transfers.
pub fn resolve_stale_dispute(env: Env, caller: Address, job_id: String) {
caller.require_auth();
Self::require_not_paused(&env);

let mut job: Job = env
.storage()
Expand Down Expand Up @@ -416,6 +451,7 @@ impl EscrowContract {
/// a full release or a dispute being raised.
pub fn cancel_job(env: Env, client: Address, job_id: String) {
client.require_auth();
Self::require_not_paused(&env);
let mut job: Job = env
.storage()
.instance()
Expand Down Expand Up @@ -1404,31 +1440,50 @@ mod tests {
assert_eq!(token.balance(&freelancer), 0);
}

// ─── Pause / emergency-stop tests ──────────────────────────────────────

#[test]
fn test_version_exposed_and_default_v1() {
let env = Env::default();
let cid = env.register_contract(None, EscrowContract);
let escrow = EscrowContractClient::new(&env, &cid);
let admin = Address::generate(&env);
escrow.initialize(&admin);
fn test_pause_and_unpause() {
let (_env, client, admin, _c, _f, _t, _j, _a, _e) = setup();
assert!(!client.is_paused());
client.pause(&admin);
assert!(client.is_paused());
client.unpause(&admin);
assert!(!client.is_paused());
}

assert_eq!(escrow.get_version(), 1);
assert_eq!(escrow.version(), 1);
#[test]
#[should_panic(expected = "Only admin can pause")]
fn test_pause_non_admin_fails() {
let (env, client, _admin, _c, _f, _t, _j, _a, _e) = setup();
let rando = Address::generate(&env);
client.pause(&rando);
}

#[test]
fn test_storage_lifetimes_and_version_key() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, EscrowContract);
let escrow = EscrowContractClient::new(&env, &cid);
let admin = Address::generate(&env);
escrow.initialize(&admin);
#[should_panic(expected = "Contract is paused")]
fn test_create_job_rejected_when_paused() {
let (env, client, admin, client_addr, freelancer, token, _j, amount, expiry) = setup();
let job_id = soroban_sdk::String::from_str(&env, "new-job");
client.pause(&admin);
client.create_job(&client_addr, &freelancer, &job_id, &token, &amount, &expiry);
}

assert_eq!(escrow.get_version(), 1);
#[test]
#[should_panic(expected = "Contract is paused")]
fn test_release_escrow_rejected_when_paused() {
let (_env, client, admin, client_addr, _f, _t, job_id, _a, _e) = setup();
client.pause(&admin);
client.release_escrow(&client_addr, &job_id);
}

env.as_contract(&cid, || {
assert!(env.storage().instance().has(&DataKey::Version));
});
#[test]
#[should_panic(expected = "Contract is paused")]
fn test_cancel_job_rejected_when_paused() {
let (env, client, admin, client_addr, _f, _t, job_id, _a, _e) = setup();
// Advance past expiry so cancel is otherwise valid
env.ledger().set_sequence_number(1001);
client.pause(&admin);
client.cancel_job(&client_addr, &job_id);
}
}
Loading
Loading