diff --git a/contracts/grant_contracts/src/cleanup_bounty.rs b/contracts/grant_contracts/src/cleanup_bounty.rs new file mode 100644 index 0000000..0984c8a --- /dev/null +++ b/contracts/grant_contracts/src/cleanup_bounty.rs @@ -0,0 +1,100 @@ +//! Cleanup Bounty — "Clean Ledger" incentive (Issue: optimization/economics) +//! +//! Any caller may invoke `finalize_and_purge` on a 100%-completed grant. +//! The contract: +//! 1. Verifies the grant is `Completed` and has zero remaining balance. +//! 2. Removes the grant's persistent storage entry (reduces state bloat). +//! 3. Pays the caller a small bounty equal to `CLEANUP_BOUNTY_BPS` of the +//! platform fee that was collected when the grant was created/funded. +//! The bounty is sourced from the treasury, so the treasury must hold +//! sufficient funds. +//! +//! Constants are intentionally conservative: 5 bps (0.05 %) of the platform +//! fee keeps the incentive meaningful without draining the treasury. + +#![allow(unused)] + +use soroban_sdk::{symbol_short, token, Address, Env}; + +use crate::{DataKey, Error, Grant, GrantContract, GrantStatus}; + +/// Bounty paid to the cleanup caller, expressed as basis points of the +/// platform fee that was collected for this grant. +/// 5 bps = 0.05 % of the platform fee. +const CLEANUP_BOUNTY_BPS: i128 = 5; + +impl GrantContract { + /// Remove a fully-completed, zero-balance grant from on-chain storage and + /// reward the caller with a small bounty sourced from the treasury. + /// + /// # Preconditions + /// - `grant.status == GrantStatus::Completed` + /// - `grant.remaining_balance == 0` (all funds have been disbursed) + /// + /// # Returns + /// The bounty amount transferred to the caller (in token stroops). + pub fn finalize_and_purge(env: Env, grant_id: u64, caller: Address) -> Result { + caller.require_auth(); + + let grant: Grant = env + .storage() + .instance() + .get(&DataKey::Grant(grant_id)) + .ok_or(Error::GrantNotFound)?; + + // Only fully-completed grants may be purged. + if grant.status != GrantStatus::Completed { + return Err(Error::InvalidState); + } + + // Guard: refuse to purge if any balance remains (shouldn't happen for + // Completed grants, but be defensive). + if grant.remaining_balance != 0 { + return Err(Error::InvalidState); + } + + // --- Bounty calculation --- + // platform_fee_bps is stored at initialisation; fall back to 0 if unset. + let platform_fee_bps: i128 = env + .storage() + .instance() + .get(&DataKey::PlatformFeeBps) + .unwrap_or(0i32) as i128; + + // platform_fee = total_amount * platform_fee_bps / 10_000 + let platform_fee = (grant.total_amount * platform_fee_bps) / 10_000; + + // bounty = platform_fee * CLEANUP_BOUNTY_BPS / 10_000 + let bounty = (platform_fee * CLEANUP_BOUNTY_BPS) / 10_000; + + // --- Transfer bounty from treasury to caller --- + if bounty > 0 { + let token_addr: Address = env + .storage() + .instance() + .get(&DataKey::GrantToken) + .ok_or(Error::NotInitialized)?; + let treasury: Address = env + .storage() + .instance() + .get(&DataKey::Treasury) + .ok_or(Error::NotInitialized)?; + + token::Client::new(&env, &token_addr).transfer( + &treasury, + &caller, + &bounty, + ); + } + + // --- Purge grant storage (the core state-bloat reduction) --- + env.storage().instance().remove(&DataKey::Grant(grant_id)); + + env.events().publish( + (symbol_short!("purged"), grant_id), + (caller, bounty), + ); + + Ok(bounty) + } +} diff --git a/contracts/grant_contracts/src/lib.rs b/contracts/grant_contracts/src/lib.rs index a4ba2c2..16fbc45 100644 --- a/contracts/grant_contracts/src/lib.rs +++ b/contracts/grant_contracts/src/lib.rs @@ -41,6 +41,11 @@ const MAX_SLASHING_REASON_LENGTH: u32 = 500; // Maximum reason string length const PAUSE_COOLDOWN_PERIOD: u64 = 14 * 24 * 60 * 60; // 14 days in seconds const SUPER_MAJORITY_THRESHOLD: u32 = 7500; // 75% super-majority threshold (in basis points) +// Issue #102: Oracle Safety Valve constants +const ORACLE_HEARTBEAT_TIMEOUT_SECS: u64 = 48 * 60 * 60; // 48 hours +const SAFETY_VOTE_DURATION_SECS: u64 = 7 * 24 * 60 * 60; // 7 days voting window +const SAFETY_APPROVAL_THRESHOLD: u32 = 9000; // 90% supermajority (in basis points) + // Gas Buffer constants const DEFAULT_GAS_BUFFER: i128 = 1_000_000; // 0.1 XLM default gas buffer (in stroops) const HIGH_NETWORK_FEE_THRESHOLD: i128 = 100_000; // 0.01 XLM threshold for high network fees @@ -94,8 +99,8 @@ pub mod sub_dao_authority; pub mod grant_appeals; pub mod wasm_hash_verification; pub mod cross_chain_metadata; -pub mod temporal_guard; pub mod yield_reserve; +pub mod cleanup_bounty; // --- Test Modules --- #[cfg(test)] @@ -647,6 +652,35 @@ pub struct SlashingProposal { pub executed_at: Option, // When slashing was executed } +// --- Oracle Safety Valve Types (Issue #102) --- + +/// Status of a DAO safety-valve vote to manually set the exchange rate. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum SafetyVoteStatus { + Active, // Voting open + Approved, // 90% supermajority reached; ready to execute + Rejected, // Failed to reach threshold + Executed, // Rate has been applied +} + +/// A DAO proposal to manually override the oracle exchange rate. +#[derive(Clone)] +#[contracttype] +pub struct SafetyVoteProposal { + pub proposal_id: u64, + pub proposer: Address, + /// Proposed rate as (numerator, denominator) — e.g. (105, 100) means 1.05 + pub rate_numerator: i128, + pub rate_denominator: i128, + pub created_at: u64, + pub voting_deadline: u64, + pub status: SafetyVoteStatus, + pub votes_for: i128, + pub votes_against: i128, + pub total_voting_power: i128, +} + // --- Milestone System Types --- #[derive(Clone)] @@ -1084,6 +1118,14 @@ pub enum DataKey { BalanceSyncRecords(u64), // Maps grant_id to list of balance sync records SafetyPauseActive, // Global safety pause flag + // Issue #102: Oracle Safety Valve keys + OracleLastHeartbeat, // Last timestamp the oracle sent a heartbeat + SafetyVoteProposal(u64), // Maps proposal_id to SafetyVoteProposal + SafetyVoteProposalIds, // List of all safety vote proposal IDs + SafetyVoteVote(u64, Address),// Maps proposal_id + voter to their vote + NextSafetyProposalId, // Next available safety proposal ID + ManualExchangeRate, // Manually set exchange rate (numerator, denominator) + #[contracterror] #[derive(Clone, Copy, Eq, PartialEq, Debug)] #[repr(u32)] @@ -1135,6 +1177,14 @@ pub enum Error { BalanceSyncRequired = 302, RegulatedAssetRestriction = 303, + // Oracle Safety Valve errors (Issue #102) + OracleStillActive = 304, // Oracle heartbeat is recent; manual override not allowed + SafetyProposalNotFound = 305, + SafetyVotingPeriodEnded = 306, + SafetyAlreadyVoted = 307, + SafetyApprovalThresholdNotMet = 308, + SafetyVotingPeriodActive = 309, + } // --- Internal Helpers --- @@ -2972,6 +3022,159 @@ impl GrantContract { Ok(()) } + // --- Issue #102: Oracle Safety Valve --- + + /// Called by the oracle to record a liveness heartbeat. + pub fn oracle_heartbeat(env: Env) -> Result<(), Error> { + require_oracle_auth(&env)?; + env.storage().instance().set(&DataKey::OracleLastHeartbeat, &env.ledger().timestamp()); + env.events().publish((symbol_short!("hrtbeat"),), env.ledger().timestamp()); + Ok(()) + } + + /// Any DAO member can propose a manual exchange rate when the oracle has been + /// silent for at least 48 hours. + pub fn propose_safety_rate( + env: Env, + proposer: Address, + rate_numerator: i128, + rate_denominator: i128, + ) -> Result { + proposer.require_auth(); + if rate_numerator <= 0 || rate_denominator <= 0 { + return Err(Error::InvalidRate); + } + + // Enforce 48-hour oracle silence requirement + let last_beat: u64 = env.storage().instance() + .get(&DataKey::OracleLastHeartbeat) + .unwrap_or(0); + let now = env.ledger().timestamp(); + if now.saturating_sub(last_beat) < ORACLE_HEARTBEAT_TIMEOUT_SECS { + return Err(Error::OracleStillActive); + } + + let total_power = get_total_voting_power(&env)?; + let proposal_id: u64 = env.storage().instance() + .get(&DataKey::NextSafetyProposalId) + .unwrap_or(0); + + let proposal = SafetyVoteProposal { + proposal_id, + proposer, + rate_numerator, + rate_denominator, + created_at: now, + voting_deadline: now + SAFETY_VOTE_DURATION_SECS, + status: SafetyVoteStatus::Active, + votes_for: 0, + votes_against: 0, + total_voting_power: total_power, + }; + + env.storage().instance().set(&DataKey::SafetyVoteProposal(proposal_id), &proposal); + env.storage().instance().set(&DataKey::NextSafetyProposalId, &(proposal_id + 1)); + + let mut ids: Vec = env.storage().instance() + .get(&DataKey::SafetyVoteProposalIds) + .unwrap_or(vec![&env]); + ids.push_back(proposal_id); + env.storage().instance().set(&DataKey::SafetyVoteProposalIds, &ids); + + env.events().publish((symbol_short!("sfvprop"), proposal_id), (rate_numerator, rate_denominator)); + Ok(proposal_id) + } + + /// DAO members vote on a safety-valve rate proposal. + pub fn vote_on_safety_rate(env: Env, voter: Address, proposal_id: u64, approve: bool) -> Result<(), Error> { + voter.require_auth(); + + let mut proposal: SafetyVoteProposal = env.storage().instance() + .get(&DataKey::SafetyVoteProposal(proposal_id)) + .ok_or(Error::SafetyProposalNotFound)?; + + if proposal.status != SafetyVoteStatus::Active { + return Err(Error::InvalidProposalStatus); + } + if env.ledger().timestamp() > proposal.voting_deadline { + return Err(Error::SafetyVotingPeriodEnded); + } + if env.storage().instance().has(&DataKey::SafetyVoteVote(proposal_id, voter.clone())) { + return Err(Error::SafetyAlreadyVoted); + } + + let power = read_voting_power(&env, &voter); + if power == 0 { return Err(Error::InsufficientVotingPower); } + + if approve { + proposal.votes_for = proposal.votes_for.checked_add(power).ok_or(Error::MathOverflow)?; + } else { + proposal.votes_against = proposal.votes_against.checked_add(power).ok_or(Error::MathOverflow)?; + } + + env.storage().instance().set(&DataKey::SafetyVoteVote(proposal_id, voter), &approve); + env.storage().instance().set(&DataKey::SafetyVoteProposal(proposal_id), &proposal); + Ok(()) + } + + /// Execute an approved safety-valve proposal to set the manual exchange rate. + /// Requires 90% approval of votes cast and oracle still silent. + pub fn execute_safety_rate(env: Env, proposal_id: u64) -> Result<(), Error> { + let mut proposal: SafetyVoteProposal = env.storage().instance() + .get(&DataKey::SafetyVoteProposal(proposal_id)) + .ok_or(Error::SafetyProposalNotFound)?; + + if proposal.status != SafetyVoteStatus::Active { + return Err(Error::InvalidProposalStatus); + } + if env.ledger().timestamp() <= proposal.voting_deadline { + return Err(Error::SafetyVotingPeriodActive); + } + + // Re-check oracle is still silent + let last_beat: u64 = env.storage().instance() + .get(&DataKey::OracleLastHeartbeat) + .unwrap_or(0); + if env.ledger().timestamp().saturating_sub(last_beat) < ORACLE_HEARTBEAT_TIMEOUT_SECS { + return Err(Error::OracleStillActive); + } + + // Require 90% supermajority of votes cast + let votes_cast = proposal.votes_for.checked_add(proposal.votes_against).ok_or(Error::MathOverflow)?; + let approval_bps = if votes_cast > 0 { + proposal.votes_for.checked_mul(10000).ok_or(Error::MathOverflow)? / votes_cast + } else { + 0 + }; + if approval_bps < SAFETY_APPROVAL_THRESHOLD as i128 { + proposal.status = SafetyVoteStatus::Rejected; + env.storage().instance().set(&DataKey::SafetyVoteProposal(proposal_id), &proposal); + return Err(Error::SafetyApprovalThresholdNotMet); + } + + // Apply the manual exchange rate + env.storage().instance().set( + &DataKey::ManualExchangeRate, + &(proposal.rate_numerator, proposal.rate_denominator), + ); + + proposal.status = SafetyVoteStatus::Executed; + env.storage().instance().set(&DataKey::SafetyVoteProposal(proposal_id), &proposal); + + env.events().publish( + (symbol_short!("sfvexec"), proposal_id), + (proposal.rate_numerator, proposal.rate_denominator), + ); + Ok(()) + } + + /// Returns the active exchange rate: manual override if set, otherwise (1, 1). + pub fn get_exchange_rate(env: Env) -> (i128, i128) { + env.storage().instance() + .get::<_, (i128, i128)>(&DataKey::ManualExchangeRate) + .unwrap_or((1, 1)) + } + pub fn manage_liquidity(env: Env, daily_liquidity: i128) -> Result<(), Error> { require_admin_auth(&env)?; if daily_liquidity < 0 { return Err(Error::InvalidAmount); } @@ -6405,6 +6608,8 @@ mod test_financial_snapshot; mod test_slashing; mod test_inflation; #[cfg(test)] +mod test_oracle_safety_valve; +#[cfg(test)] mod test_yield; #[cfg(test)] mod test_fee; diff --git a/contracts/grant_contracts/src/test_oracle_safety_valve.rs b/contracts/grant_contracts/src/test_oracle_safety_valve.rs new file mode 100644 index 0000000..3256398 --- /dev/null +++ b/contracts/grant_contracts/src/test_oracle_safety_valve.rs @@ -0,0 +1,127 @@ +#![cfg(test)] +extern crate std; + +use crate::{Error, GrantContract, GrantContractClient}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn setup(env: &Env) -> (GrantContractClient<'_>, Address, Address) { + let contract_id = env.register(GrantContract, ()); + let client = GrantContractClient::new(env, &contract_id); + + let admin = Address::generate(env); + let oracle = Address::generate(env); + let treasury = Address::generate(env); + let grant_token = Address::generate(env); + let native_token = Address::generate(env); + + client.initialize(&admin, &grant_token, &treasury, &oracle, &native_token); + (client, admin, oracle) +} + +/// Oracle heartbeat records a timestamp; a fresh heartbeat blocks proposals. +#[test] +fn test_heartbeat_blocks_proposal() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle) = setup(&env); + + // Set total voting power so proposals can be created + // (reuse set_total_voting_power via admin path — here we just test the guard) + client.oracle_heartbeat(); + + let proposer = Address::generate(&env); + let result = client.try_propose_safety_rate(&proposer, &105, &100); + assert_eq!(result, Err(Ok(Error::OracleStillActive))); +} + +/// After 48 h of silence a proposal can be created and executed with 90% votes. +#[test] +fn test_full_safety_valve_flow() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle) = setup(&env); + + // Advance time past the 48-hour window without a heartbeat + env.ledger().with_mut(|l| l.timestamp = 48 * 60 * 60 + 1); + + // Seed total voting power (100 units) + // The contract reads TotalVotingPower; set it directly via the existing + // set_voting_power path used in slashing tests. + let voter_a = Address::generate(&env); + let voter_b = Address::generate(&env); + client.set_voting_power(&voter_a, &90); + client.set_voting_power(&voter_b, &10); + client.set_total_voting_power(&100); + + // Propose a manual rate of 1.05 (105/100) + let proposal_id = client.propose_safety_rate(&voter_a, &105, &100); + assert_eq!(proposal_id, 0); + + // Advance past the voting deadline + env.ledger().with_mut(|l| l.timestamp += 7 * 24 * 60 * 60 + 1); + + // voter_a (90 power) votes yes — that's 90% of 100 total + client.vote_on_safety_rate(&voter_a, &proposal_id, &true); + + // Execute: 90/100 = 90% ≥ threshold + client.execute_safety_rate(&proposal_id); + + let (num, den) = client.get_exchange_rate(); + assert_eq!(num, 105); + assert_eq!(den, 100); +} + +/// Execution fails when approval is below 90%. +#[test] +fn test_safety_valve_rejected_below_threshold() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle) = setup(&env); + + env.ledger().with_mut(|l| l.timestamp = 48 * 60 * 60 + 1); + + let voter_a = Address::generate(&env); + let voter_b = Address::generate(&env); + client.set_voting_power(&voter_a, &89); + client.set_voting_power(&voter_b, &11); + client.set_total_voting_power(&100); + + let proposal_id = client.propose_safety_rate(&voter_a, &110, &100); + + env.ledger().with_mut(|l| l.timestamp += 7 * 24 * 60 * 60 + 1); + + // Only 89% approval — below the 90% threshold + client.vote_on_safety_rate(&voter_a, &proposal_id, &true); + client.vote_on_safety_rate(&voter_b, &proposal_id, &false); + + let result = client.try_execute_safety_rate(&proposal_id); + assert_eq!(result, Err(Ok(Error::SafetyApprovalThresholdNotMet))); +} + +/// If the oracle sends a heartbeat after a proposal is created, execution is blocked. +#[test] +fn test_oracle_recovery_blocks_execution() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _oracle) = setup(&env); + + env.ledger().with_mut(|l| l.timestamp = 48 * 60 * 60 + 1); + + let voter = Address::generate(&env); + client.set_voting_power(&voter, &100); + client.set_total_voting_power(&100); + + let proposal_id = client.propose_safety_rate(&voter, &105, &100); + + // Oracle comes back online + env.ledger().with_mut(|l| l.timestamp += 1); + client.oracle_heartbeat(); + + // Advance past voting deadline + env.ledger().with_mut(|l| l.timestamp += 7 * 24 * 60 * 60 + 1); + client.vote_on_safety_rate(&voter, &proposal_id, &true); + + // Execution should fail because oracle is now active + let result = client.try_execute_safety_rate(&proposal_id); + assert_eq!(result, Err(Ok(Error::OracleStillActive))); +}