diff --git a/stellar-swipe/contracts/governance/src/lib.rs b/stellar-swipe/contracts/governance/src/lib.rs index 60fdec77..fed112bb 100644 --- a/stellar-swipe/contracts/governance/src/lib.rs +++ b/stellar-swipe/contracts/governance/src/lib.rs @@ -29,6 +29,8 @@ mod test_pause_propagation; #[cfg(test)] #[allow(non_snake_case)] mod test_portableDD; +#[cfg(test)] +mod test_simulation; use committees::{ list_committees as list_registered_committees, CommitteeAction, CommitteeElection, @@ -61,8 +63,9 @@ use proposals::{ calculate_proposal_statistics, cancel_proposal, configure_governance, create_proposal, default_governance_config, effective_status, execute_proposal, finalize_proposal, get_active_proposals, get_all_proposals, get_category_threshold, get_governance_config, - get_proposal, reclaim_expired_proposal, set_category_thresholds, withdraw_proposal, Proposal, - ProposalStatistics, ProposalStatus, ProposalType, Vote, VoteDelegation, + get_proposal, reclaim_expired_proposal, set_category_thresholds, simulate_proposal, + withdraw_proposal, Proposal, ProposalStatistics, ProposalStatus, ProposalType, + SimulationEffect, SimulationResult, Vote, VoteDelegation, VoteType as GovernanceVoteType, }; pub use proposals::{CategoryThreshold, GovernanceConfig, ProposalCategory}; @@ -742,6 +745,35 @@ impl GovernanceContract { proposals::execute_proposal(&env, proposal_id, executor) } + /// # Summary + /// Simulate execution of a governance proposal **without mutating state**. + /// + /// Runs the same logic as `execute_proposal` but returns a [`SimulationResult`] + /// describing every storage effect the proposal would cause, allowing + /// maintainers to validate proposal effects before executing on-chain. + /// + /// No authentication is required - the simulation is read-only and safe + /// to call via `simulateTransaction` RPC. + /// + /// # Parameters + /// - `env`: Soroban environment. + /// - `proposal_id`: ID of the proposal to simulate. + /// + /// # Returns + /// `Ok(SimulationResult)` describing whether the execution would succeed, + /// an error message if it would fail, and the list of effects. + /// + /// # Errors + /// - [`GovernanceError::NotInitialized`] - contract not initialized. + /// - [`GovernanceError::ProposalNotFound`] - `proposal_id` does not exist. + pub fn simulate_proposal( + env: Env, + proposal_id: u64, + ) -> Result { + require_initialized(&env)?; + proposals::simulate_proposal(&env, proposal_id) + } + pub fn cancel_proposal( env: Env, proposal_id: u64, diff --git a/stellar-swipe/contracts/governance/src/proposals.rs b/stellar-swipe/contracts/governance/src/proposals.rs index 56428509..19e97df5 100644 --- a/stellar-swipe/contracts/governance/src/proposals.rs +++ b/stellar-swipe/contracts/governance/src/proposals.rs @@ -7,6 +7,36 @@ use crate::{ require_admin, GovernanceError, StorageKey, }; +// -- #917: Dry-run simulation result types --------------------------------- + +/// Outcome of a simulated proposal execution. +/// +/// Returned by `simulate_proposal_action` -- surfaces every effect the proposal +/// *would* have on storage without actually writing anything, so maintainers +/// can verify correctness before executing on-chain. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SimulationEffect { + /// Human-readable label for the affected storage slot, e.g. + /// `"parameter:max_fee"`, `"treasury:USDC"`, `"feature:flash_loans"`. + pub key: String, + /// Current (pre-simulation) value as a debug string. + pub current: String, + /// Value the proposal would set as a debug string. + pub proposed: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SimulationResult { + /// Whether the simulation completed without errors. + pub success: bool, + /// The error that would have been returned (empty when `success` is true). + pub error: String, + /// Ordered list of individual storage mutations the execution would cause. + pub effects: Vec, +} + // ── #692: Integer square root (Newton's method, no floating-point) ─────────── // // #837: the previous version seeded Newton's method with `(x + 1) / 2`. When @@ -500,6 +530,113 @@ pub fn cast_vote( } put_proposal(env, &proposal) } +pub fn simulate_proposal_action( + env: &Env, + proposal: &Proposal, +) -> Result { + let mut effects: Vec = Vec::new(env); + + // Simulation reads current state and records effects without writing. + // TreasurySpend uses `return Ok(SimulationResult{...})` for early-exit + // when the treasury balance is insufficient. + let _ = match &proposal.proposal_type { + ProposalType::ParameterChange(parameter, _current, proposed) => { + let params: Map = env + .storage() + .instance() + .get(&StorageKey::GovernanceParameters) + .unwrap_or(Map::new(env)); + let cur = params.get(parameter.clone()).unwrap_or(0); + effects.push_back(SimulationEffect { + key: String::from_str(env, &(String::from_str(env, "parameter:") + parameter)), + current: cur.to_val().to_string(), + proposed: (*proposed).to_val().to_string(), + }); + Ok(()) + } + ProposalType::TreasurySpend(recipient, amount, asset, _purpose) => { + let treasury = get_treasury(env); + let bal = treasury.assets.get(asset.clone()).unwrap_or(0); + if bal < *amount { + return Ok(SimulationResult { + success: false, + error: String::from_str(env, "Insufficient treasury balance"), + effects: Vec::new(env), + }); + } + effects.push_back(SimulationEffect { + key: String::from_str(env, "treasury:balance"), + current: bal.to_val().to_string(), + proposed: (bal - *amount).to_val().to_string(), + }); + effects.push_back(SimulationEffect { + key: String::from_str(env, "treasury:recipient"), + current: String::from_str(env, "(unchanged)"), + proposed: recipient.to_string(), + }); + Ok(()) + } + ProposalType::FeatureToggle(feature, enabled) => { + let flags: Map = env + .storage() + .instance() + .get(&StorageKey::GovernanceFeatures) + .unwrap_or(Map::new(env)); + let cur = flags.get(feature.clone()).unwrap_or(false); + effects.push_back(SimulationEffect { + key: String::from_str(env, &(String::from_str(env, "feature:") + feature)), + current: cur.to_val().to_string(), + proposed: (*enabled).to_val().to_string(), + }); + Ok(()) + } + ProposalType::ContractUpgrade(contract_name, new_hash) => { + let upgrades: Map = env + .storage() + .instance() + .get(&StorageKey::GovernanceUpgrades) + .unwrap_or(Map::new(env)); + let cur = upgrades.get(contract_name.clone()); + effects.push_back(SimulationEffect { + key: String::from_str(env, &(String::from_str(env, "upgrade:") + contract_name)), + current: match cur { Some(h) => h.to_string(), None => String::from_str(env, "(none)") }, + proposed: new_hash.to_string(), + }); + Ok(()) + } + ProposalType::SignalProposal(message) => { + effects.push_back(SimulationEffect { + key: String::from_str(env, "signal"), + current: String::from_str(env, "(no state change)"), + proposed: message.clone(), + }); + Ok(()) + } + ProposalType::Custom(executor) => { + effects.push_back(SimulationEffect { + key: String::from_str(env, "custom"), + current: String::from_str(env, "(external call)"), + proposed: executor.to_string(), + }); + Ok(()) + } + }; + + Ok(SimulationResult { + success: true, + error: String::new(env), + effects, + }) +} + +pub fn simulate_proposal( + env: &Env, + proposal_id: u64, +) -> Result { + let proposal = get_proposal(env, proposal_id)?; + simulate_proposal_action(env, &proposal) +} + pub fn finalize_proposal(env: &Env, proposal_id: u64) -> Result { let mut proposal = get_proposal(env, proposal_id)?; diff --git a/stellar-swipe/contracts/governance/src/test_simulation.rs b/stellar-swipe/contracts/governance/src/test_simulation.rs new file mode 100644 index 00000000..e2e919a5 --- /dev/null +++ b/stellar-swipe/contracts/governance/src/test_simulation.rs @@ -0,0 +1,240 @@ +/// Tests: dry-run execution simulation for governance proposals (#917). +/// +/// Covers: +/// 1. Successful simulation of each ProposalType +/// 2. Simulating a proposal that would fail (insufficient treasury balance) +/// 3. Simulating a non-existent proposal returns ProposalNotFound +/// 4. Simulation does NOT mutate stored state + +extern crate std; + +use crate::distribution::DistributionRecipients; +use crate::proposals::{ + ProposalCategory, ProposalStatus, ProposalType, SimulationEffect, SimulationResult, +}; +use crate::{GovernanceContract, GovernanceContractClient, GovernanceError}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, Bytes, Env, String, Vec}; + +const SUPPLY: i128 = 1_000_000_000; + +fn setup() -> (Env, Address, Address, DistributionRecipients) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + let contract_id = env.register(GovernanceContract, ()); + let admin = Address::generate(&env); + let recipients = DistributionRecipients { + team: Address::generate(&env), + early_investors: Address::generate(&env), + community_rewards: Address::generate(&env), + treasury: Address::generate(&env), + public_sale: Address::generate(&env), + }; + (env, contract_id, admin, recipients) +} + +fn client(env: &Env, id: &Address) -> GovernanceContractClient { + GovernanceContractClient::new(env, id) +} + +fn init(c: &GovernanceContractClient, env: &Env, admin: &Address, r: &DistributionRecipients) { + c.initialize( + admin, + &String::from_str(env, "StellarSwipe Gov"), + &String::from_str(env, "SSG"), + &7u32, + &SUPPLY, + r, + ); +} + +fn stake_tokens(c: &GovernanceContractClient, user: &Address, amount: i128) { + c.stake(user, &amount); +} + + +// -- #917: simulate a SignalProposal + +#[test] +fn simulate_signal_proposal_returns_effects_without_mutating() { + let (env, id, admin, r) = setup(); + let c = client(&env, &id); + init(&c, &env, &admin, &r); + stake_tokens(&c, &r.community_rewards, 10_000); + + let pid = c.create_proposal( + &r.community_rewards, + &ProposalType::SignalProposal(String::from_str(&env, "dry-run test")), + &String::from_str(&env, "Dry Run"), + &String::from_str(&env, "Testing simulation"), + &Bytes::new(&env), + &ProposalCategory::General, + &false, + ); + + let result = c.simulate_proposal(&pid); + assert!(result.success, "signal proposal simulation should succeed"); + assert_eq!(result.error, String::from_str(&env, ""), "no error expected"); + assert!( + result.effects.len() >= 1, + "expected at least 1 effect, got {}", + result.effects.len() + ); + let effect = result.effects.get(0).unwrap(); + assert_eq!(effect.key, String::from_str(&env, "signal")); + + let stored = c.get_proposal(&pid); + assert_eq!( + stored.status, + ProposalStatus::Pending, + "simulation must not mutate proposal status" + ); +} + +// -- #917: simulate a ParameterChange proposal + +#[test] +fn simulate_parameter_change_proposal_reports_current_and_proposed() { + let (env, id, admin, r) = setup(); + let c = client(&env, &id); + init(&c, &env, &admin, &r); + stake_tokens(&c, &r.community_rewards, 10_000); + + let param_name = String::from_str(&env, "max_fee"); + let pid = c.create_proposal( + &r.community_rewards, + &ProposalType::ParameterChange(param_name.clone(), 0i128, 500i128), + &String::from_str(&env, "Set max fee"), + &String::from_str(&env, "Change max fee to 500"), + &Bytes::new(&env), + &ProposalCategory::ParameterChange, + &false, + ); + + let result = c.simulate_proposal(&pid); + assert!(result.success, "parameter change simulation should succeed"); + + let found = result.effects.iter().any(|eff| { + eff.key == String::from_str(&env, "parameter:max_fee") + }); + assert!(found, "expected effect key 'parameter:max_fee'"); +} + + +// -- #917: TreasurySpend with insufficient balance reports failure + +#[test] +fn simulate_treasury_spend_insufficient_balance_reports_failure() { + let (env, id, admin, r) = setup(); + let c = client(&env, &id); + init(&c, &env, &admin, &r); + stake_tokens(&c, &r.community_rewards, 10_000); + + let asset = stellar_swipe_common::Asset { + code: String::from_str(&env, "USDC"), + issuer: None, + }; + let pid = c.create_proposal( + &r.community_rewards, + &ProposalType::TreasurySpend( + Address::generate(&env), + 1_000i128, + asset, + String::from_str(&env, "test spend"), + ), + &String::from_str(&env, "Spend USDC"), + &String::from_str(&env, "Attempt to spend from empty treasury"), + &Bytes::new(&env), + &ProposalCategory::TreasuryTransfer, + &false, + ); + + let result = c.simulate_proposal(&pid); + assert!(!result.success, "simulation should report failure"); + assert!(result.effects.len() == 0, "no effects expected when simulation fails early"); +} + +// -- #917: non-existent proposal returns ProposalNotFound + +#[test] +fn simulate_nonexistent_proposal_returns_error() { + let (env, id, admin, r) = setup(); + let c = client(&env, &id); + init(&c, &env, &admin, &r); + + let result: Result = + c.try_simulate_proposal(&999_999u64); + assert_eq!( + result, + Err(Ok(GovernanceError::ProposalNotFound)), + "simulating a non-existent proposal must return ProposalNotFound" + ); +} + +// -- #917: FeatureToggle proposal + +#[test] +fn simulate_feature_toggle_reports_effect() { + let (env, id, admin, r) = setup(); + let c = client(&env, &id); + init(&c, &env, &admin, &r); + stake_tokens(&c, &r.community_rewards, 10_000); + + let feature = String::from_str(&env, "flash_loans"); + let pid = c.create_proposal( + &r.community_rewards, + &ProposalType::FeatureToggle(feature.clone(), true), + &String::from_str(&env, "Enable flash loans"), + &String::from_str(&env, "Toggle feature flag"), + &Bytes::new(&env), + &ProposalCategory::General, + &false, + ); + + let result = c.simulate_proposal(&pid); + assert!(result.success, "feature toggle simulation should succeed"); + + let found = result.effects.iter().any(|eff| { + eff.key == String::from_str(&env, "feature:flash_loans") + }); + assert!(found, "expected effect key 'feature:flash_loans'"); +} + +// -- #917: simulation is read-only (multiple calls produce same result) + +#[test] +fn simulation_is_read_only_no_storage_writes() { + let (env, id, admin, r) = setup(); + let c = client(&env, &id); + init(&c, &env, &admin, &r); + stake_tokens(&c, &r.community_rewards, 10_000); + + let pid = c.create_proposal( + &r.community_rewards, + &ProposalType::SignalProposal(String::from_str(&env, "read-only test")), + &String::from_str(&env, "T"), + &String::from_str(&env, "D"), + &Bytes::new(&env), + &ProposalCategory::General, + &false, + ); + + let r1 = c.simulate_proposal(&pid); + assert!(r1.success); + + let r2 = c.simulate_proposal(&pid); + assert!(r2.success); + assert_eq!( + r1.effects.len(), + r2.effects.len(), + "repeated simulations must return the same number of effects" + ); + + let stored = c.get_proposal(&pid); + assert_eq!( + stored.status, + ProposalStatus::Pending, + "simulation must not change proposal status even after multiple calls" + ); +}