diff --git a/Cargo.lock b/Cargo.lock index 9aa3812e..b644fd73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -703,6 +703,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "emergency_vault" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "enum-ordinalize" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index 53b89b64..2a33e900 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,3 @@ [workspace] resolver = "2" -members = ["contracts/aegis_vault", "contracts/helphone_dao"] -# Standalone crates that keep their own lockfiles / workspaces. -exclude = ["contract", "contracts/emergency_vault", "contracts/maintainer_vault"] - -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -panic = "abort" -strip = true -overflow-checks = true +members = ["contracts/*"] diff --git a/contracts/helphone_dao/src/lib.rs b/contracts/helphone_dao/src/lib.rs index 2f730e96..00804e98 100644 --- a/contracts/helphone_dao/src/lib.rs +++ b/contracts/helphone_dao/src/lib.rs @@ -3,18 +3,19 @@ mod oracle; use soroban_sdk::{ - contract, contractclient, contracterror, contractevent, contractimpl, contracttype, - symbol_short, Address, Env, Symbol, Vec, + contract, contracterror, contractevent, contractimpl, contracttype, symbol_short, Address, Env, + IntoVal, Symbol, Val, Vec as SorobanVec, }; pub use oracle::{OracleAsset, PriceData, MAX_PRICE_AGE_SECS}; // ── Constants ────────────────────────────────────────────────────── -const MAX_PROPOSALS: u64 = 100; +const MAX_PROPOSALS: u32 = 100; +const MAX_SECURITY_GUARDIANS: u32 = 20; const VOTING_PERIOD_SECS: u64 = 3 * 24 * 60 * 60; // 3 days -const EXECUTION_DELAY_SECS: u64 = 1 * 24 * 60 * 60; // 1 day timelock +const EXECUTION_DELAY_SECS: u64 = 48 * 60 * 60; const QUORUM_THRESHOLD_PCT: u32 = 20; // 20% of total supply must vote -const PASS_THRESHOLD_PCT: u32 = 50; // >50% of votes to pass +const PASS_THRESHOLD_PCT: u32 = 50; // >50% of votes to pass /// Governance-token surface the DAO reads (`total_supply` is not part of the /// generic SEP-41 client, so it is declared here). @@ -32,11 +33,15 @@ pub enum DataKey { GovernanceToken, ProposalCount, Proposal(u64), - Vote(u64, Address), // (proposal_id, voter) -> VoteRecord - TokenSnapshot(u64), // proposal_id -> TokenSnapshot - TotalSupplyAt(u64), // proposal_id -> total token supply at snapshot + Vote(u64, Address), // (proposal_id, voter) -> VoteRecord + TokenSnapshot(u64), // proposal_id -> TokenSnapshot + TotalSupplyAt(u64), // proposal_id -> total token supply at snapshot ExecutedProposals, - Oracle, + Timelock(u64), + SecurityKeys, + SecurityThreshold, + SecurityApproval(u64, u32, Address), + SecurityEpoch, } // ── Types ────────────────────────────────────────────────────────── @@ -48,6 +53,7 @@ pub enum ProposalStatus { Failed, Executed, Cancelled, + Queued, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -101,6 +107,13 @@ pub struct TokenSnapshot { pub snapshot_ledger: u32, } +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct TimelockState { + pub queued_at: u64, + pub execute_after: u64, +} + // ── Errors ───────────────────────────────────────────────────────── #[contracterror] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -117,13 +130,14 @@ pub enum DaoError { TimelockNotExpired = 10, ExecutionFailed = 11, ProposalLimitReached = 12, - OracleNotSet = 13, - /// Oracle price is older than `MAX_PRICE_AGE_SECS`. - StalePrice = 14, - PriceUnavailable = 15, - InvalidPrice = 16, - InvalidAmount = 17, - Overflow = 18, + QueueRequired = 13, + NotSecurityGuardian = 14, + InvalidSecurityThreshold = 15, + InsufficientSecurityApprovals = 16, + TimelockOverflow = 17, + ProposalNotQueued = 18, + TimelockExpired = 19, + SecurityEpochOverflow = 20, } // ── Events ───────────────────────────────────────────────────────── @@ -159,10 +173,32 @@ pub struct ProposalExecutedEvent<'a> { pub success: &'a bool, } -fn key_admin() -> Symbol { symbol_short!("admin") } -fn key_token() -> Symbol { symbol_short!("token") } -fn key_proposal_count() -> Symbol { symbol_short!("pcount") } -fn key_executed_set() -> Symbol { symbol_short!("execd") } +#[contractevent(topics = ["queued"], data_format = "map")] +pub struct ProposalQueuedEvent<'a> { + #[topic] + pub proposal_id: &'a u64, + pub execute_after: &'a u64, +} + +#[contractevent(topics = ["cancelled"], data_format = "map")] +pub struct ProposalCancelledEvent<'a> { + #[topic] + pub proposal_id: &'a u64, + pub approvals: &'a u32, +} + +fn key_admin() -> Symbol { + symbol_short!("admin") +} +fn key_token() -> Symbol { + symbol_short!("token") +} +fn key_proposal_count() -> Symbol { + symbol_short!("pcount") +} +fn key_executed_set() -> Symbol { + symbol_short!("execd") +} #[contract] pub struct HelPhoneDao; @@ -176,8 +212,19 @@ impl HelPhoneDao { governance_token: Address, ) -> Result<(), DaoError> { env.storage().instance().set(&key_admin(), &admin); - env.storage().instance().set(&key_token(), &governance_token); + env.storage() + .instance() + .set(&key_token(), &governance_token); env.storage().instance().set(&key_proposal_count(), &0u64); + let mut security_keys = SorobanVec::new(&env); + security_keys.push_back(admin); + env.storage() + .instance() + .set(&DataKey::SecurityKeys, &security_keys); + env.storage() + .instance() + .set(&DataKey::SecurityThreshold, &1u32); + env.storage().instance().set(&DataKey::SecurityEpoch, &0u32); Ok(()) } @@ -193,7 +240,10 @@ impl HelPhoneDao { /// Returns the current proposal count. pub fn get_proposal_count(env: Env) -> u64 { - env.storage().instance().get(&key_proposal_count()).unwrap_or(0u64) + env.storage() + .instance() + .get(&key_proposal_count()) + .unwrap_or(0u64) } /// Returns governance parameters as a tuple. @@ -207,6 +257,57 @@ impl HelPhoneDao { ) } + /// Configure the authorized security signers used for emergency + /// cancellation. The admin must provide unique keys and a reachable threshold. + pub fn set_security_multisig( + env: Env, + admin: Address, + guardians: SorobanVec
, + threshold: u32, + ) -> Result<(), DaoError> { + admin.require_auth(); + let stored_admin: Address = env + .storage() + .instance() + .get(&key_admin()) + .ok_or(DaoError::NotAdmin)?; + if admin != stored_admin { + return Err(DaoError::NotAdmin); + } + if guardians.is_empty() + || guardians.len() > MAX_SECURITY_GUARDIANS + || threshold == 0 + || threshold > guardians.len() + { + return Err(DaoError::InvalidSecurityThreshold); + } + for i in 0..guardians.len() { + for j in (i + 1)..guardians.len() { + if guardians.get(i) == guardians.get(j) { + return Err(DaoError::InvalidSecurityThreshold); + } + } + } + let epoch: u32 = env + .storage() + .instance() + .get(&DataKey::SecurityEpoch) + .unwrap_or(0); + let next_epoch = epoch + .checked_add(1) + .ok_or(DaoError::SecurityEpochOverflow)?; + env.storage() + .instance() + .set(&DataKey::SecurityKeys, &guardians); + env.storage() + .instance() + .set(&DataKey::SecurityThreshold, &threshold); + env.storage() + .instance() + .set(&DataKey::SecurityEpoch, &next_epoch); + Ok(()) + } + /// Create a new proposal. Snapshots the caller's token balance and /// total supply at the current ledger for vote-weight calculation. pub fn create_proposal( @@ -245,7 +346,9 @@ impl HelPhoneDao { // Snapshot token total supply for quorum calculation let token_addr: Address = env - .storage().instance().get(&key_token()) + .storage() + .instance() + .get(&key_token()) .ok_or(DaoError::InvalidProposal)?; let token_client = GovTokenClient::new(&env, &token_addr); let total_supply = token_client.total_supply(); @@ -255,10 +358,18 @@ impl HelPhoneDao { snapshot_ledger: env.ledger().sequence(), }; - env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); - env.storage().persistent().set(&DataKey::TotalSupplyAt(proposal_id), &total_supply); - env.storage().persistent().set(&DataKey::TokenSnapshot(proposal_id), &snapshot); - env.storage().instance().set(&key_proposal_count(), &proposal_id); + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage() + .persistent() + .set(&DataKey::TotalSupplyAt(proposal_id), &total_supply); + env.storage() + .persistent() + .set(&DataKey::TokenSnapshot(proposal_id), &snapshot); + env.storage() + .instance() + .set(&key_proposal_count(), &proposal_id); ProposalCreatedEvent { proposal_id: &proposal_id, @@ -281,7 +392,9 @@ impl HelPhoneDao { voter.require_auth(); let mut proposal: Proposal = env - .storage().persistent().get(&DataKey::Proposal(proposal_id)) + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) .ok_or(DaoError::ProposalNotFound)?; if proposal.status != ProposalStatus::Active { @@ -301,13 +414,17 @@ impl HelPhoneDao { // Get voter's token balance for weight let token_addr: Address = env - .storage().instance().get(&key_token()) + .storage() + .instance() + .get(&key_token()) .ok_or(DaoError::InvalidProposal)?; let token_client = GovTokenClient::new(&env, &token_addr); // Use the snapshot ledger for historical balance let snapshot: TokenSnapshot = env - .storage().persistent().get(&DataKey::TokenSnapshot(proposal_id)) + .storage() + .persistent() + .get(&DataKey::TokenSnapshot(proposal_id)) .ok_or(DaoError::InvalidProposal)?; let weight = token_client.balance(&voter); @@ -331,7 +448,9 @@ impl HelPhoneDao { VoteDirection::Against => proposal.against_votes += weight, VoteDirection::Abstain => proposal.abstain_votes += weight, } - env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); VoteCastEvent { proposal_id: &proposal_id, @@ -346,12 +465,11 @@ impl HelPhoneDao { /// Finalize a proposal after voting ends. Checks quorum and pass /// threshold, then updates status. - pub fn finalize_proposal( - env: Env, - proposal_id: u64, - ) -> Result+ Passed proposals must be queued and wait 48 hours before permissionless execution. Security guardians can approve emergency cancellation. +
++ Status: {daoStatus} +
+ {daoStatus === "Queued" && ( ++ {secondsRemaining + ? `Execution available in ${Math.floor(secondsRemaining / 3600)}h ${Math.floor((secondsRemaining % 3600) / 60)}m ${secondsRemaining % 60}s` + : "Timelock expired; execution is available."} +
+ )} ++ Emergency approvals: {daoProposal.cancellationApprovals}/{daoProposal.cancellationThreshold} +
+