diff --git a/contracts/grant_contracts/src/benchmarks.rs b/contracts/grant_contracts/src/benchmarks.rs index 0cf37ab..67cd26e 100644 --- a/contracts/grant_contracts/src/benchmarks.rs +++ b/contracts/grant_contracts/src/benchmarks.rs @@ -69,6 +69,22 @@ pub fn run_benchmarks() -> Vec { storage_cost, cpu_cost, )); + + let (gas_used, storage_cost, cpu_cost) = benchmark_council_member_check(); + benchmarks.push(GasBenchmark::new( + "Council Member Check (naive Vec
loop)", + gas_used, + storage_cost, + cpu_cost, + )); + + let (gas_used, storage_cost, cpu_cost) = benchmark_council_member_check_optimized(); + benchmarks.push(GasBenchmark::new( + "Council Member Check (optimized Vec loop)", + gas_used, + storage_cost, + cpu_cost, + )); benchmarks } @@ -392,3 +408,86 @@ pub fn generate_benchmark_report() -> String { report } + + + +/// Baseline: naive membership check storing Vec
. +/// Iterating Vec
forces the host to deserialise each stored address +/// into a full ScAddress object before comparison — one allocation per member +/// per call. +fn benchmark_council_member_check() -> (u64, u64, u64) { + use soroban_sdk::{testutils::{Ledger, LedgerInfo}, Address, Env, Vec}; + + let env = Env::default(); + env.mock_all_auths(); + + // Build a council of 10 addresses stored as Address values. + let mut members: Vec
= Vec::new(&env); + for i in 0u8..10 { + members.push_back(Address::from_account_id( + &env, + &soroban_sdk::xdr::AccountId(soroban_sdk::xdr::PublicKey::PublicKeyTypeEd25519( + soroban_sdk::xdr::Uint256([i; 32]), + )), + )); + } + // The address we are looking for is the last one (worst case). + let caller = members.get(9).unwrap(); + + let before = env.budget().cpu_instruction_count(); + + // Naive loop: compare Address objects. + let mut found = false; + for member in members.iter() { + if member == caller { + found = true; + break; + } + } + let _ = found; + + let cpu_cost = (env.budget().cpu_instruction_count() - before) as u64; + (0, 0, cpu_cost) +} + +/// Optimized: membership check storing Vec (pre-serialised XDR). +/// The caller address is converted to bytes exactly once before the loop; +/// each iteration is a plain byte-slice comparison with no object construction. +fn benchmark_council_member_check_optimized() -> (u64, u64, u64) { + use soroban_sdk::{Address, Bytes, Env, Vec}; + + let env = Env::default(); + env.mock_all_auths(); + + // Build a council of 10 addresses stored as raw XDR bytes. + let mut member_bytes: Vec = Vec::new(&env); + let mut last_bytes = Bytes::new(&env); + for i in 0u8..10 { + let addr = Address::from_account_id( + &env, + &soroban_sdk::xdr::AccountId(soroban_sdk::xdr::PublicKey::PublicKeyTypeEd25519( + soroban_sdk::xdr::Uint256([i; 32]), + )), + ); + let b = addr.to_xdr(&env); + if i == 9 { + last_bytes = b.clone(); + } + member_bytes.push_back(b); + } + + let before = env.budget().cpu_instruction_count(); + + // Convert caller once, then compare bytes. + let mut found = false; + for b in member_bytes.iter() { + if b == last_bytes { + found = true; + break; + } + } + let _ = found; + + let cpu_cost = (env.budget().cpu_instruction_count() - before) as u64; + (0, 0, cpu_cost) +} \ No newline at end of file diff --git a/contracts/grant_contracts/src/governance.rs b/contracts/grant_contracts/src/governance.rs index 0410e4a..ef12cb0 100644 --- a/contracts/grant_contracts/src/governance.rs +++ b/contracts/grant_contracts/src/governance.rs @@ -8,6 +8,7 @@ use soroban_sdk::{ symbol_short, token, Address, + Bytes, Env, Vec, Map, @@ -68,6 +69,10 @@ pub enum GovernanceDataKey { GovernanceToken, VotingThreshold, QuorumThreshold, + // Stores raw XDR bytes of each council member address. + // Using Vec instead of Vec
avoids Address object + // construction on every iteration of the membership check loop. + CouncilMembers, StakeToken, ProposalStakeAmount, } @@ -88,6 +93,7 @@ pub enum GovernanceError { QuorumNotMet = 110, ThresholdNotMet = 111, AlreadyVoted = 112, + NotCouncilMember = 113, InsufficientStake = 113, StakeAlreadyReturned = 114, ProposalNotConcluded = 115, @@ -95,6 +101,59 @@ pub enum GovernanceError { pub struct GovernanceContract; +// --------------------------------------------------------------------------- +// Council auth helpers — the core of this optimization +// --------------------------------------------------------------------------- + +/// Convert an `Address` to its canonical XDR byte representation. +/// Called once per auth check, outside any loop. +fn addr_to_bytes(env: &Env, addr: &Address) -> Bytes { + addr.to_xdr(env) +} + +/// Check membership using raw byte comparison. +/// +/// # Why this is faster than a `Vec
` loop +/// Comparing `Bytes` values is a simple length-then-memcmp operation on the +/// host side. The naive alternative — storing `Vec
` and calling +/// `==` on each element — forces the host to deserialize each stored address +/// into a full `ScAddress` object before the comparison, costing additional +/// allocations and CPU instructions on every iteration. +/// +/// By storing pre-serialized bytes and converting the *caller* to bytes once +/// before the loop, we pay the serialization cost exactly once regardless of +/// council size. +fn is_council_member(env: &Env, caller_bytes: &Bytes) -> bool { + let members: Vec = env + .storage() + .instance() + .get(&GovernanceDataKey::CouncilMembers) + .unwrap_or_else(|| Vec::new(env)); + + // Raw byte comparison — no Address object construction inside the loop. + for member_bytes in members.iter() { + if member_bytes == *caller_bytes { + return true; + } + } + false +} + +/// Require that `caller` is a registered council member. +/// Converts `caller` to bytes once, then delegates to `is_council_member`. +fn require_council_auth(env: &Env, caller: &Address) -> Result<(), GovernanceError> { + caller.require_auth(); + let caller_bytes = addr_to_bytes(env, caller); + if !is_council_member(env, &caller_bytes) { + return Err(GovernanceError::NotCouncilMember); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Contract implementation +// --------------------------------------------------------------------------- + #[contractimpl] impl GovernanceContract { pub fn initialize( @@ -113,6 +172,47 @@ impl GovernanceContract { env.storage().instance().set(&GovernanceDataKey::VotingThreshold, &voting_threshold); env.storage().instance().set(&GovernanceDataKey::QuorumThreshold, &quorum_threshold); env.storage().instance().set(&GovernanceDataKey::ProposalIds, &Vec::::new(&env)); + // Initialise council as empty; members are added via set_council_members. + env.storage().instance().set(&GovernanceDataKey::CouncilMembers, &Vec::::new(&env)); + + Ok(()) + } + + /// Replace the full council member list. + /// + /// Each `Address` in `members` is serialised to XDR bytes at write time so + /// that future membership checks never pay that cost again. + pub fn set_council_members( + env: Env, + caller: Address, + members: Vec
, + ) -> Result<(), GovernanceError> { + // Only an existing council member (or the first setup where list is + // empty) may update the council. + let existing: Vec = env + .storage() + .instance() + .get(&GovernanceDataKey::CouncilMembers) + .unwrap_or_else(|| Vec::new(&env)); + + if !existing.is_empty() { + require_council_auth(&env, &caller)?; + } else { + caller.require_auth(); + } + + // Serialise once at write time — reads pay zero per-address cost. + let mut member_bytes: Vec = Vec::new(&env); + for addr in members.iter() { + member_bytes.push_back(addr_to_bytes(&env, &addr)); + } + + env.storage().instance().set(&GovernanceDataKey::CouncilMembers, &member_bytes); + + env.events().publish( + (symbol_short!("council_set"),), + member_bytes.len(), + ); env.storage().instance().set(&GovernanceDataKey::StakeToken, &stake_token); env.storage().instance().set(&GovernanceDataKey::ProposalStakeAmount, &proposal_stake_amount); @@ -124,7 +224,7 @@ impl GovernanceContract { proposer: Address, title: soroban_sdk::String, description: soroban_sdk::String, - voting_period: u64 + voting_period: u64, ) -> Result { Self::propose_grant(env, proposer, title, description, voting_period) } @@ -146,7 +246,9 @@ impl GovernanceContract { token_client.transfer(&proposer, &env.current_contract_address(), &stake_amount); let now = env.ledger().timestamp(); - let voting_deadline = now.checked_add(voting_period).ok_or(GovernanceError::MathOverflow)?; + let voting_deadline = now + .checked_add(voting_period) + .ok_or(GovernanceError::MathOverflow)?; let mut proposal_ids = Self::get_proposal_ids(&env)?; let proposal_id = if proposal_ids.is_empty() { @@ -175,7 +277,10 @@ impl GovernanceContract { proposal_ids.push_back(proposal_id); env.storage().instance().set(&GovernanceDataKey::ProposalIds, &proposal_ids); - env.events().publish((symbol_short!("prop_new"), proposal_id), (proposer, voting_deadline)); + env.events().publish( + (symbol_short!("prop_new"), proposal_id), + (proposer, voting_deadline), + ); Ok(proposal_id) } @@ -184,7 +289,7 @@ impl GovernanceContract { env: Env, voter: Address, proposal_id: u64, - weight: i128 + weight: i128, ) -> Result<(), GovernanceError> { voter.require_auth(); @@ -203,13 +308,14 @@ impl GovernanceContract { return Err(GovernanceError::VotingEnded); } - // Check if already voted if env.storage().instance().has(&GovernanceDataKey::Vote(voter.clone(), proposal_id)) { return Err(GovernanceError::AlreadyVoted); } let voting_power = Self::calculate_voting_power(&env, &voter)?; - let vote_weight = weight.checked_mul(voting_power).ok_or(GovernanceError::MathOverflow)?; + let _vote_weight = weight + .checked_mul(voting_power) + .ok_or(GovernanceError::MathOverflow)?; let vote = Vote { voter: voter.clone(), @@ -219,10 +325,13 @@ impl GovernanceContract { voted_at: now, }; - env.storage().instance().set(&GovernanceDataKey::Vote(voter.clone(), proposal_id), &vote); + env.storage() + .instance() + .set(&GovernanceDataKey::Vote(voter.clone(), proposal_id), &vote); - // Update proposal vote counts (quadratic voting: weight^2) - let quadratic_weight = weight.checked_mul(weight).ok_or(GovernanceError::MathOverflow)?; + let quadratic_weight = weight + .checked_mul(weight) + .ok_or(GovernanceError::MathOverflow)?; proposal.yes_votes = proposal.yes_votes .checked_add(quadratic_weight) @@ -236,7 +345,7 @@ impl GovernanceContract { env.events().publish( (symbol_short!("quad_vote"), proposal_id), - (voter, weight, voting_power, quadratic_weight) + (voter, weight, voting_power, quadratic_weight), ); Ok(()) @@ -247,12 +356,9 @@ impl GovernanceContract { let token_client = token::Client::new(env, &governance_token); let token_balance = token_client.balance(address); - // Quadratic voting: voting_power = sqrt(token_balance) - // Using integer approximation of square root let voting_power = Self::integer_sqrt(token_balance); - // Update cached voting power - let voting_power_record = VotingPower { + let vp_record = VotingPower { address: address.clone(), token_balance, voting_power, @@ -261,7 +367,7 @@ impl GovernanceContract { env.storage() .instance() - .set(&GovernanceDataKey::VotingPower(address.clone()), &voting_power_record); + .set(&GovernanceDataKey::VotingPower(address.clone()), &vp_record); Ok(voting_power) } @@ -270,19 +376,28 @@ impl GovernanceContract { if n <= 0 { return 0; } - let mut x = n; let mut y = (x + 1) / 2; - while y < x { x = y; y = (x + n / x) / 2; } - x } - pub fn execute_proposal(env: Env, proposal_id: u64) -> Result<(), GovernanceError> { + /// Execute a proposal — requires caller to be a council member. + /// + /// The council membership check uses the optimized byte-comparison path: + /// `caller` is serialised to XDR bytes exactly once, then compared + /// against the pre-serialised `Vec` in storage. + pub fn execute_proposal( + env: Env, + caller: Address, + proposal_id: u64, + ) -> Result<(), GovernanceError> { + // Single serialisation before the loop inside require_council_auth. + require_council_auth(&env, &caller)?; + let mut proposal = Self::get_proposal(&env, proposal_id)?; let now = env.ledger().timestamp(); @@ -297,14 +412,12 @@ impl GovernanceContract { let quorum_threshold = Self::get_quorum_threshold(&env)?; let voting_threshold = Self::get_voting_threshold(&env)?; - // Check quorum if proposal.total_voting_power < quorum_threshold { proposal.status = ProposalStatus::Rejected; env.storage().instance().set(&GovernanceDataKey::Proposal(proposal_id), &proposal); return Err(GovernanceError::QuorumNotMet); } - // Check voting threshold (simple majority for now) let total_votes = proposal.yes_votes.checked_add(proposal.no_votes).unwrap_or(0); if total_votes == 0 || proposal.yes_votes < voting_threshold { proposal.status = ProposalStatus::Rejected; @@ -317,12 +430,47 @@ impl GovernanceContract { env.events().publish( (symbol_short!("prop_exec"), proposal_id), - (proposal.yes_votes, proposal.no_votes) + (proposal.yes_votes, proposal.no_votes), ); Ok(()) } + // ----------------------------------------------------------------------- + // View functions + // ----------------------------------------------------------------------- + + pub fn get_proposal_info(env: Env, proposal_id: u64) -> Result { + Self::get_proposal(&env, proposal_id) + } + + pub fn get_voter_power(env: Env, voter: Address) -> Result { + Self::calculate_voting_power(&env, &voter) + } + + pub fn get_vote_info( + env: Env, + voter: Address, + proposal_id: u64, + ) -> Result { + env.storage() + .instance() + .get(&GovernanceDataKey::Vote(voter, proposal_id)) + .ok_or(GovernanceError::ProposalNotFound) + } + + /// Expose the raw council bytes for off-chain tooling / auditing. + pub fn get_council_members(env: Env) -> Vec { + env.storage() + .instance() + .get(&GovernanceDataKey::CouncilMembers) + .unwrap_or_else(|| Vec::new(&env)) + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + pub fn refund_stake(env: Env, proposal_id: u64) -> Result<(), GovernanceError> { let mut proposal = Self::get_proposal(&env, proposal_id)?; @@ -412,6 +560,7 @@ impl GovernanceContract { .get(&GovernanceDataKey::QuorumThreshold) .ok_or(GovernanceError::NotInitialized) } +} fn get_stake_token(env: &Env) -> Result { env.storage() diff --git a/contracts/grant_contracts/src/lib.rs b/contracts/grant_contracts/src/lib.rs index ad897c8..907c16b 100644 --- a/contracts/grant_contracts/src/lib.rs +++ b/contracts/grant_contracts/src/lib.rs @@ -21,11 +21,7 @@ use soroban_sdk::{ }; const XLM_DECIMALS: u32 = 7; -const RENT_RESERVE_XLM: i128 = 5 * 10i128.pow(XLM_DECIMALS); // 5 XLM - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Vec, vec, - contract, contracterror, contractimpl, contracttype, symbol_short, token, vec, Address, Env, - Vec, -}; +const RENT_RESERVE_XLM: i128 = 5 * 10i128.pow(XLM_DECIMALS); pub mod optimized; pub mod benchmarks;