From 7c180494017475c2ed071782103dc74ca6ae6f37 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 17 Oct 2026 16:29:06 +0100 Subject: [PATCH] feat(quadratic): overflow-safe isqrt, sybil gate, commit-reveal, exact pool split (#1626) - math.rs: checked isqrt and quadratic cost - Funding checks sybil_resistance is_verified when configured - Commit-reveal donation rounds - Distribution assigns rounding remainder so payouts equal the pool --- contracts/quadratic_funding/src/lib.rs | 349 ++++++++++++++++++++---- contracts/quadratic_funding/src/test.rs | 206 +++++++++++++- contracts/quadratic_voting/src/lib.rs | 46 +++- contracts/quadratic_voting/src/math.rs | 66 +++++ contracts/quadratic_voting/src/test.rs | 68 +++++ 5 files changed, 666 insertions(+), 69 deletions(-) create mode 100644 contracts/quadratic_voting/src/math.rs diff --git a/contracts/quadratic_funding/src/lib.rs b/contracts/quadratic_funding/src/lib.rs index 95138fe62..cbc19ccb5 100644 --- a/contracts/quadratic_funding/src/lib.rs +++ b/contracts/quadratic_funding/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -//! # Quadratic Funding & Voting Allocation Engine — SC-HARD-14 +//! # Quadratic Funding & Voting Allocation Engine — SC-HARD-14 / SC-005 //! //! A Gitcoin-style quadratic funding (QF) mechanism where a central matching //! pool is distributed to projects proportional to the *square* of the sum of @@ -14,29 +14,43 @@ //! match(p) = (Σ sqrt(a_i))² //! ``` //! -//! The matching pool is then distributed proportionally: +//! The matching pool is then distributed proportionally, with floor rounding +//! and any dust (the rounding remainder) assigned to the project with the +//! largest weight so that payouts always sum to exactly the pool: //! //! ```text -//! payout(p) = match(p) / Σ_all_projects match(q) × pool +//! payout(p) = floor(match(p) / Σ_all_projects match(q) × pool) //! ``` //! //! ## Sybil Resistance -//! Each address may donate to a given project **at most once** per round. -//! The admin maintains a whitelist of approved donors; only whitelisted -//! addresses may contribute. +//! - Each address may donate to a given project **at most once** per round. +//! - Donors must be on the admin whitelist. +//! - If a sybil-resistance contract is configured via `set_sybil_contract`, +//! every donor must also pass `is_verified(donor)` on that contract. +//! +//! ## Commit-reveal (anti-frontrunning) +//! 1. Commit phase (default): donors call `commit_donation` with a hash. +//! 2. Admin calls `open_reveal`, closing the commit phase. +//! 3. Reveal phase: donors call `reveal_donation` with the preimage fields. +//! The commitment is checked and the donation is applied via the same +//! internal path as `donate`. +//! +//! Commitment preimage (see [`commitment_hash`]): +//! `"QF-COMMIT-V1" || donor_xdr || project_id(u64, BE) || amount(i128, BE) || salt(32 bytes)` //! //! ## Security //! - Integer overflow: all arithmetic uses `checked_*`. -//! - Precision: square-root is computed with integer Newton's method scaled -//! by `SCALE = 1_000_000` to preserve 6 decimal places of precision. -//! - No external calls during state mutation. +//! - Precision: square-root is computed with integer arithmetic scaled by +//! `SCALE = 1_000_000` to preserve 6 decimal places of precision. +//! - Errors are reported with panics and clear messages. //! //! ## Storage TTL (SC-HARD-16) //! Every state-mutating call bumps instance TTL beyond 100,000 ledgers to //! protect against automatic ledger state archival on Stellar Mainnet. use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, Env, Map, Symbol, Vec, + contract, contractimpl, contracttype, symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env, + IntoVal, Map, Symbol, Vec, }; // ── TTL constants (SC-HARD-16) ─────────────────────────────────────────────── @@ -51,6 +65,11 @@ const PROJECTS_KEY: Symbol = symbol_short!("PROJECTS"); const WHITELIST_KEY: Symbol = symbol_short!("WLIST"); const ROUND_OPEN: Symbol = symbol_short!("OPEN"); const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); +const SYBIL_KEY: Symbol = symbol_short!("SYBIL"); +const REVEAL_OPEN: Symbol = symbol_short!("REVEAL"); + +/// Domain separator prepended to every commitment preimage. +const COMMIT_DOMAIN: &[u8] = b"QF-COMMIT-V1"; /// Fixed-point scale for sqrt precision (6 decimal places). const SCALE: i128 = 1_000_000; @@ -71,6 +90,42 @@ pub struct Project { pub donors: Map, } +/// Persistent keys for per-(donor, project) commitments. +#[contracttype] +#[derive(Clone)] +pub enum QfKey { + /// Commitment hash stored by `commit_donation`. + Commit(Address, u32), +} + +// ── Commitment helper ───────────────────────────────────────────────────────── + +/// Builds the commit-reveal hash for a donation. +/// +/// Preimage layout (byte-exact): +/// 1. `"QF-COMMIT-V1"` (12 bytes, ASCII domain separator) +/// 2. donor address, XDR-serialised (variable length) +/// 3. `project_id` as `u64`, big-endian (8 bytes) +/// 4. `amount` as `i128`, big-endian (16 bytes) +/// 5. `salt` (32 bytes) +/// +/// The fields after the donor XDR have fixed lengths, so the encoding is +/// unambiguous. Returns `sha256(preimage)`. +pub fn commitment_hash( + env: &Env, + donor: &Address, + project_id: u32, + amount: i128, + salt: &BytesN<32>, +) -> BytesN<32> { + let mut preimage = Bytes::from_slice(env, COMMIT_DOMAIN); + preimage.append(&donor.clone().to_xdr(env)); + preimage.append(&Bytes::from_array(env, &u64::from(project_id).to_be_bytes())); + preimage.append(&Bytes::from_array(env, &amount.to_be_bytes())); + preimage.append(&Bytes::from_array(env, &salt.to_array())); + env.crypto().sha256(&preimage).to_bytes() +} + // ── Contract ────────────────────────────────────────────────────────────────── #[contract] @@ -82,6 +137,8 @@ impl QuadraticFunding { /// Initialise a new funding round with a matching `pool`. /// + /// The round starts in the commit phase (reveal not open). + /// /// # Panics /// - If a round is already open. /// - If `pool` is not positive. @@ -100,10 +157,25 @@ impl QuadraticFunding { env.storage() .instance() .set(&WHITELIST_KEY, &Map::::new(&env)); + env.storage().instance().set(&REVEAL_OPEN, &false); env.storage().instance().set(&ROUND_OPEN, &true); bump_instance(&env); } + // ── Admin: sybil contract ───────────────────────────────────────────────── + + /// Configure the sybil-resistance contract consulted by `donate` and + /// `reveal_donation`. The contract must expose `is_verified(Address) -> bool`. + /// + /// # Panics + /// - If `admin` is not the round admin. + pub fn set_sybil_contract(env: Env, admin: Address, sybil: Address) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + env.storage().instance().set(&SYBIL_KEY, &sybil); + bump_instance(&env); + } + // ── Admin: register project ─────────────────────────────────────────────── /// Register a new project with id `project_id`. @@ -154,63 +226,119 @@ impl QuadraticFunding { /// # Panics /// - If the round is not open. /// - If `donor` is not whitelisted. + /// - If a sybil contract is configured and `donor` is not sybil-verified. /// - If `donor` has already donated to this project. /// - If `amount` is not positive. pub fn donate(env: Env, donor: Address, project_id: u32, amount: i128) { + donor.require_auth(); + Self::process_donation(&env, &donor, project_id, amount); + } + + // ── Commit-reveal ───────────────────────────────────────────────────────── + + /// Commit to a future donation. Only allowed during the commit phase + /// (before `open_reveal`). The commitment is `commitment_hash(...)`. + /// + /// # Panics + /// - If the round is not open or the reveal phase has started. + /// - If the project does not exist. + /// - If `donor` already has a commitment for `project_id`. + pub fn commit_donation(env: Env, donor: Address, project_id: u32, commitment: BytesN<32>) { donor.require_auth(); Self::assert_round_open(&env); - assert!(amount > 0, "amount must be positive"); + assert!(!Self::is_reveal_open(&env), "commit phase closed"); - // Sybil check - let wl: Map = env.storage().instance().get(&WHITELIST_KEY).unwrap(); + let projects: Map = env.storage().instance().get(&PROJECTS_KEY).unwrap(); + assert!(projects.contains_key(project_id), "project not found"); + + let key = QfKey::Commit(donor.clone(), project_id); assert!( - wl.get(donor.clone()).unwrap_or(false), - "donor not whitelisted" + !env.storage().persistent().has(&key), + "already committed to this project" ); + env.storage().persistent().set(&key, &commitment); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, INSTANCE_BUMP); - let mut projects: Map = env.storage().instance().get(&PROJECTS_KEY).unwrap(); - let mut project = projects.get(project_id).expect("project not found"); - - // One donation per donor per project (Sybil guard). - assert!( - !project.donors.contains_key(donor.clone()), - "already donated to this project" + env.events().publish( + (symbol_short!("committed"), donor.clone()), + project_id, ); + bump_instance(&env); + } - project.donors.set(donor.clone(), amount); - project.total_donations = project - .total_donations - .checked_add(amount) - .expect("overflow"); + /// Close the commit phase and open the reveal phase. Admin only. + /// + /// # Panics + /// - If `admin` is not the round admin. + /// - If the round is not open. + /// - If the reveal phase is already open. + pub fn open_reveal(env: Env, admin: Address) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + Self::assert_round_open(&env); + assert!(!Self::is_reveal_open(&env), "reveal already open"); - // sqrt_sum += isqrt(amount * SCALE²) = isqrt(amount) * SCALE - let sqrt_contribution = Self::isqrt(amount.checked_mul(SCALE * SCALE).expect("overflow")); - project.sqrt_sum = project - .sqrt_sum - .checked_add(sqrt_contribution) - .expect("overflow"); + env.storage().instance().set(&REVEAL_OPEN, &true); + bump_instance(&env); + } - projects.set(project_id, project); - env.storage().instance().set(&PROJECTS_KEY, &projects); + /// Reveal a previously committed donation and apply it. + /// + /// The hash of `(donor, project_id, amount, salt)` must match the stored + /// commitment. The commitment is consumed on success, so it cannot be replayed. + /// + /// # Panics + /// - If the round is not open or the reveal phase is not open. + /// - If there is no commitment for `(donor, project_id)`. + /// - If the recomputed hash does not match the commitment (wrong salt/amount). + /// - Any check performed by the donation path (whitelist, sybil, duplicate, amount). + pub fn reveal_donation( + env: Env, + donor: Address, + project_id: u32, + amount: i128, + salt: BytesN<32>, + ) { + donor.require_auth(); + Self::assert_round_open(&env); + assert!(Self::is_reveal_open(&env), "reveal phase not open"); - env.events() - .publish((symbol_short!("donated"), donor), (project_id, amount)); - bump_instance(&env); + let key = QfKey::Commit(donor.clone(), project_id); + let committed: BytesN<32> = env + .storage() + .persistent() + .get(&key) + .expect("no commitment for this donor and project"); + + let digest = commitment_hash(&env, &donor, project_id, amount, &salt); + assert!(digest == committed, "commitment mismatch"); + + env.storage().persistent().remove(&key); + Self::process_donation(&env, &donor, project_id, amount); } // ── Distribute ──────────────────────────────────────────────────────────── /// Close the round and distribute the matching pool to projects. /// - /// Returns a `Vec` of `(project_id, match_amount)` pairs. + /// Returns a `Vec` of `(project_id, match_amount)` pairs, in project-id order. + /// + /// Each project receives `floor(pool × weight / total_weight)`. The rounding + /// remainder (dust) is added to the project with the largest weight; ties go + /// to the lowest project id. The payouts therefore always sum to exactly the + /// pool. If no project has a non-zero weight, no payouts are made and the + /// round still closes. /// /// The quadratic formula ensures that projects with **more unique donors** /// receive proportionally more matching, even if their total donation amount - /// is equal to a project with fewer whale donors. This maximises capital - /// allocation toward community-favored public goods. + /// is equal to a project with fewer whale donors. /// /// # Panics + /// - If `admin` is not the round admin. /// - If the round is not open. + /// - On arithmetic overflow. pub fn distribute(env: Env, admin: Address) -> Vec<(u32, i128)> { admin.require_auth(); Self::assert_admin(&env, &admin); @@ -219,12 +347,11 @@ impl QuadraticFunding { let pool: i128 = env.storage().instance().get(&POOL_KEY).unwrap(); let projects: Map = env.storage().instance().get(&PROJECTS_KEY).unwrap(); - // Compute match weight for each project: (sqrt_sum)² + // Compute match weight for each project: (sqrt_sum)² / SCALE². let mut total_weight: i128 = 0; let mut weights: Vec<(u32, i128)> = Vec::new(&env); for (id, project) in projects.iter() { - // weight = (sqrt_sum)² / SCALE² normalises back to token units. let weight = project .sqrt_sum .checked_mul(project.sqrt_sum) @@ -244,16 +371,36 @@ impl QuadraticFunding { return payouts; } + // Pass 1: floor shares; track total distributed and the largest weight. + let mut distributed: i128 = 0; + let mut largest_idx: u32 = 0; + let mut largest_weight: i128 = -1; for i in 0..weights.len() { let (id, weight) = weights.get(i).unwrap(); - let match_amount = pool + let share = pool .checked_mul(weight) .expect("overflow") .checked_div(total_weight) - .unwrap_or(0); - payouts.push_back((id, match_amount)); - env.events() - .publish((symbol_short!("payout"),), (id, match_amount)); + .expect("division by zero"); + distributed = distributed.checked_add(share).expect("overflow"); + if weight > largest_weight { + largest_weight = weight; + largest_idx = i; + } + payouts.push_back((id, share)); + } + + // Pass 2: assign rounding dust to the largest-weight project. + // distributed <= pool because every floor share is <= its exact share. + let remainder = pool.checked_sub(distributed).expect("overflow"); + if remainder > 0 { + let (id, share) = payouts.get(largest_idx).unwrap(); + let adjusted = share.checked_add(remainder).expect("overflow"); + payouts.set(largest_idx, (id, adjusted)); + } + + for (id, amount) in payouts.iter() { + env.events().publish((symbol_short!("payout"),), (id, amount)); } // Close the round. @@ -276,6 +423,11 @@ impl QuadraticFunding { env.storage().instance().get(&POOL_KEY).unwrap_or(0) } + /// Returns `true` once the reveal phase has been opened. + pub fn is_reveal_phase(env: Env) -> bool { + Self::is_reveal_open(&env) + } + // ── SC-037 Penalty Slasher & Collusion Detection ────────────────────────── /// Deposit security stake for a donor. @@ -307,20 +459,104 @@ impl QuadraticFunding { // ── Internal helpers ────────────────────────────────────────────────────── - /// Integer square root via Newton's method (no floating point). + /// Shared donation path used by `donate` and `reveal_donation`. + /// Performs no authentication; callers must `require_auth` first. + fn process_donation(env: &Env, donor: &Address, project_id: u32, amount: i128) { + Self::assert_round_open(env); + assert!(amount > 0, "amount must be positive"); + + // Whitelist check. + let wl: Map = env.storage().instance().get(&WHITELIST_KEY).unwrap(); + assert!( + wl.get(donor.clone()).unwrap_or(false), + "donor not whitelisted" + ); + + // Sybil-resistance contract check (when configured). + Self::check_sybil(env, donor); + + let mut projects: Map = env.storage().instance().get(&PROJECTS_KEY).unwrap(); + let mut project = projects.get(project_id).expect("project not found"); + + // One donation per donor per project (Sybil guard). + assert!( + !project.donors.contains_key(donor.clone()), + "already donated to this project" + ); + + project.donors.set(donor.clone(), amount); + project.total_donations = project + .total_donations + .checked_add(amount) + .expect("overflow"); + + // sqrt_sum += isqrt(amount * SCALE²) = isqrt(amount) * SCALE + let scaled = amount + .checked_mul(SCALE) + .and_then(|v| v.checked_mul(SCALE)) + .expect("overflow"); + let sqrt_contribution = Self::isqrt(scaled); + project.sqrt_sum = project + .sqrt_sum + .checked_add(sqrt_contribution) + .expect("overflow"); + + projects.set(project_id, project); + env.storage().instance().set(&PROJECTS_KEY, &projects); + + env.events() + .publish((symbol_short!("donated"), donor.clone()), (project_id, amount)); + bump_instance(env); + } + + /// Calls `is_verified(donor)` on the configured sybil contract, if any. + /// Panics if the donor is not verified. Does nothing when no contract is set. + fn check_sybil(env: &Env, donor: &Address) { + let sybil: Option
= env.storage().instance().get(&SYBIL_KEY); + if let Some(sybil_contract) = sybil { + let verified: bool = env.invoke_contract( + &sybil_contract, + &Symbol::new(env, "is_verified"), + soroban_sdk::vec![env, donor.into_val(env)], + ); + assert!(verified, "donor not sybil verified"); + } + } + + /// Integer square root via the bitwise digit method (no floating point, + /// no unchecked `x + 1` or `x + n / x` steps). /// - /// Returns `floor(sqrt(n))`. + /// Returns `floor(sqrt(n))`, or `0` for `n <= 0`. pub(crate) fn isqrt(n: i128) -> i128 { 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; + let mut num = n as u128; + let mut res: u128 = 0; + // Highest power of four <= num. Every intermediate stays below 2^127. + let mut bit: u128 = 1u128 << 126; + while bit > num { + bit >>= 2; + } + while bit != 0 { + let candidate = res.checked_add(bit).expect("overflow"); + if num >= candidate { + num = num.checked_sub(candidate).expect("overflow"); + res = (res >> 1).checked_add(bit).expect("overflow"); + } else { + res >>= 1; + } + bit >>= 2; } - x + // The root of a positive i128 is at most 2^63, so this cast is lossless. + res as i128 + } + + fn is_reveal_open(env: &Env) -> bool { + env.storage() + .instance() + .get::(&REVEAL_OPEN) + .unwrap_or(false) } fn assert_admin(env: &Env, caller: &Address) { @@ -359,4 +595,3 @@ pub(crate) fn bump_instance(env: &Env) { #[cfg(test)] mod test; - diff --git a/contracts/quadratic_funding/src/test.rs b/contracts/quadratic_funding/src/test.rs index bb58d7f7d..ea385d35c 100644 --- a/contracts/quadratic_funding/src/test.rs +++ b/contracts/quadratic_funding/src/test.rs @@ -13,7 +13,29 @@ use super::*; use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Bytes, Env}; +use soroban_sdk::{Bytes, BytesN, Env}; + +// ── Mock sybil-resistance contracts ─────────────────────────────────────────── + +#[contract] +struct MockSybilOk; + +#[contractimpl] +impl MockSybilOk { + pub fn is_verified(_env: Env, _user: Address) -> bool { + true + } +} + +#[contract] +struct MockSybilReject; + +#[contractimpl] +impl MockSybilReject { + pub fn is_verified(_env: Env, _user: Address) -> bool { + false + } +} // ── Helper ──────────────────────────────────────────────────────────────────── @@ -363,3 +385,185 @@ fn test_slash_collusion_rejects_non_guilty_donor() { let res = client.try_slash_collusion(&investigator, &1, &colluders, &proof); assert_eq!(res, Err(Ok(SlasherError::DonorNotGuilty))); } + +// ── SC-005 tests ────────────────────────────────────────────────────────────── + +#[test] +fn test_isqrt_boundaries_and_max() { + assert_eq!(QuadraticFunding::isqrt(0), 0); + assert_eq!(QuadraticFunding::isqrt(-7), 0); + assert_eq!(QuadraticFunding::isqrt(3), 1); + assert_eq!(QuadraticFunding::isqrt(99), 9); + assert_eq!(QuadraticFunding::isqrt(100), 10); + // Must not panic at the type boundary; floor(sqrt(MAX)) ≈ 1.3e19. + let r = QuadraticFunding::isqrt(i128::MAX); + assert!(r.checked_mul(r).unwrap() <= i128::MAX); +} + +#[test] +fn test_sybil_unverified_donor_rejected_in_donate() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + let donor = Address::generate(&env); + let owner = Address::generate(&env); + let sybil = env.register(MockSybilReject, ()); + + client.init(&admin, &10_000); + client.set_sybil_contract(&admin, &sybil); + client.register_project(&admin, &1, &owner); + client.whitelist_donor(&admin, &donor); + + let res = client.try_donate(&donor, &1, &100); + assert!(res.is_err(), "unverified sybil donor must be rejected"); + assert_eq!(client.get_project(&1).total_donations, 0); +} + +#[test] +fn test_sybil_verified_donor_accepted_in_donate() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + let donor = Address::generate(&env); + let owner = Address::generate(&env); + let sybil = env.register(MockSybilOk, ()); + + client.init(&admin, &10_000); + client.set_sybil_contract(&admin, &sybil); + client.register_project(&admin, &1, &owner); + client.whitelist_donor(&admin, &donor); + client.donate(&donor, &1, &100); + assert_eq!(client.get_project(&1).total_donations, 100); +} + +#[test] +fn test_distribute_sums_exactly_to_pool() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + client.init(&admin, &10_000); + for id in 1u32..=3 { + let owner = Address::generate(&env); + client.register_project(&admin, &id, &owner); + let d = Address::generate(&env); + client.whitelist_donor(&admin, &d); + client.donate(&d, &id, &100); + } + + let payouts = client.distribute(&admin); + assert_eq!(payouts.len(), 3); + let mut sum = 0i128; + for i in 0..payouts.len() { + sum += payouts.get(i).unwrap().1; + } + assert_eq!(sum, 10_000); + // Equal weights: 3333 each, remainder 1 goes to the first largest-weight project. + assert_eq!(payouts.get(0).unwrap(), (1, 3334)); + assert_eq!(payouts.get(1).unwrap(), (2, 3333)); + assert_eq!(payouts.get(2).unwrap(), (3, 3333)); +} + +#[test] +fn test_distribute_uneven_weights_sums_exactly_to_pool() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + client.init(&admin, &9_999); + let amounts = [7i128, 13, 29]; + for (idx, amount) in amounts.iter().enumerate() { + let id = (idx as u32) + 1; + let owner = Address::generate(&env); + client.register_project(&admin, &id, &owner); + let d = Address::generate(&env); + client.whitelist_donor(&admin, &d); + client.donate(&d, &id, amount); + } + + let payouts = client.distribute(&admin); + let mut sum = 0i128; + for i in 0..payouts.len() { + sum += payouts.get(i).unwrap().1; + } + assert_eq!(sum, 9_999); + // Round is closed: a second distribute must fail. + assert!(client.try_distribute(&admin).is_err()); +} + +#[test] +fn test_commit_then_reveal_succeeds() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + let donor = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin, &10_000); + client.register_project(&admin, &1, &owner); + client.whitelist_donor(&admin, &donor); + + let salt = BytesN::from_array(&env, &[7u8; 32]); + let hash = commitment_hash(&env, &donor, 1, 100, &salt); + client.commit_donation(&donor, &1, &hash); + + client.open_reveal(&admin); + assert!(client.is_reveal_phase()); + client.reveal_donation(&donor, &1, &100, &salt); + + let project = client.get_project(&1); + assert_eq!(project.total_donations, 100); + assert!(project.donors.contains_key(donor)); +} + +#[test] +fn test_reveal_with_wrong_salt_fails() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + let donor = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin, &10_000); + client.register_project(&admin, &1, &owner); + client.whitelist_donor(&admin, &donor); + + let salt = BytesN::from_array(&env, &[7u8; 32]); + let wrong_salt = BytesN::from_array(&env, &[8u8; 32]); + let hash = commitment_hash(&env, &donor, 1, 100, &salt); + client.commit_donation(&donor, &1, &hash); + client.open_reveal(&admin); + + let res = client.try_reveal_donation(&donor, &1, &100, &wrong_salt); + assert!(res.is_err(), "reveal with wrong salt must fail"); + // The commitment is still intact and the correct reveal still works. + assert_eq!(client.get_project(&1).total_donations, 0); + client.reveal_donation(&donor, &1, &100, &salt); + assert_eq!(client.get_project(&1).total_donations, 100); +} + +#[test] +fn test_commit_after_reveal_opened_fails() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + let donor = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin, &10_000); + client.register_project(&admin, &1, &owner); + client.whitelist_donor(&admin, &donor); + client.open_reveal(&admin); + + let salt = BytesN::from_array(&env, &[1u8; 32]); + let hash = commitment_hash(&env, &donor, 1, 100, &salt); + assert!(client.try_commit_donation(&donor, &1, &hash).is_err()); +} + +#[test] +fn test_reveal_before_open_fails() { + let (env, contract_id, admin) = setup(); + let client = QuadraticFundingClient::new(&env, &contract_id); + let donor = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin, &10_000); + client.register_project(&admin, &1, &owner); + client.whitelist_donor(&admin, &donor); + + let salt = BytesN::from_array(&env, &[1u8; 32]); + let hash = commitment_hash(&env, &donor, 1, 100, &salt); + client.commit_donation(&donor, &1, &hash); + assert!(client.try_reveal_donation(&donor, &1, &100, &salt).is_err()); +} diff --git a/contracts/quadratic_voting/src/lib.rs b/contracts/quadratic_voting/src/lib.rs index fbc05bcd2..1fb529155 100644 --- a/contracts/quadratic_voting/src/lib.rs +++ b/contracts/quadratic_voting/src/lib.rs @@ -6,6 +6,8 @@ #![no_std] +pub mod math; + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, panic_with_error, Address, BytesN, Env, IntoVal, String, Symbol, @@ -33,6 +35,8 @@ pub enum QVError { NotSybilVerified = 445, /// `446` — Proposal was not found. ProposalNotFound = 446, + /// `447` — Arithmetic overflow while computing votes or cost. + Overflow = 447, } #[contracttype] @@ -129,7 +133,9 @@ impl QuadraticVotingContract { .instance() .get(&DataKey::ProposalCount) .unwrap_or(0); - count += 1; + count = count + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, QVError::Overflow)); let proposal = Proposal { id: count, @@ -187,19 +193,33 @@ impl QuadraticVotingContract { .persistent() .get(&DataKey::UserVotes(voter.clone(), proposal_id)) .unwrap_or(0); - let new_total_votes = previous_votes + additional_votes; - - // Quadratic cost logic: Total cost should be (total_votes)^2. - let total_cost = new_total_votes.pow(2); - let previous_cost = previous_votes.pow(2); - let incremental_cost = total_cost - previous_cost; - - if current_credits < incremental_cost { + let new_total_votes = previous_votes + .checked_add(additional_votes) + .unwrap_or_else(|| panic_with_error!(&env, QVError::Overflow)); + + // Quadratic cost logic: incremental cost = (prev + add)^2 - prev^2, + // so that the total cost of V votes is V^2. + let incremental_cost = math::quadratic_cost( + u64::from(previous_votes), + u64::from(additional_votes), + ) + .unwrap_or_else(|| panic_with_error!(&env, QVError::Overflow)); + + if i128::from(current_credits) < incremental_cost { panic_with_error!(&env, QVError::InsufficientCredits); } - current_credits -= incremental_cost; - proposal.votes_received += additional_votes; + let remaining = i128::from(current_credits) + .checked_sub(incremental_cost) + .unwrap_or_else(|| panic_with_error!(&env, QVError::InsufficientCredits)); + // remaining <= current_credits <= u32::MAX, so this conversion succeeds. + current_credits = u32::try_from(remaining) + .unwrap_or_else(|_| panic_with_error!(&env, QVError::Overflow)); + + proposal.votes_received = proposal + .votes_received + .checked_add(additional_votes) + .unwrap_or_else(|| panic_with_error!(&env, QVError::Overflow)); env.storage() .persistent() @@ -280,6 +300,10 @@ impl QuadraticVotingContract { /// Returns the matching pool weight for a voter. Only voters with /// verified DID credentials (proof_count > 0) receive non-zero weight. /// Unverified voters (score = 0) always return 0 matching pool weight. + /// + /// NOTE: this weight is LINEAR in the humanity score (score * 10). It is + /// not the quadratic consensus weight used by `quadratic_funding::distribute`. + /// Kept unchanged for compatibility. pub fn get_matching_pool_weight(env: Env, voter: Address) -> u32 { let score: u32 = Self::get_humanity_score(env, voter.clone()); // Unverified account: zero matching pool weight. diff --git a/contracts/quadratic_voting/src/math.rs b/contracts/quadratic_voting/src/math.rs new file mode 100644 index 000000000..49f4b3e0d --- /dev/null +++ b/contracts/quadratic_voting/src/math.rs @@ -0,0 +1,66 @@ +//! Overflow-safe integer math for quadratic voting (SC-005). +//! +//! All helpers avoid floating point and use checked arithmetic. No helper +//! performs an unchecked `x + 1` or `x + n / x` step, so inputs at the type +//! boundary (e.g. `i128::MAX`) cannot panic. + +/// Integer square root: returns `floor(sqrt(n))` for an unsigned 128-bit value. +/// +/// Uses the classic digit-by-digit (bitwise) method. Every intermediate value +/// stays below `2^127`, so the additions below cannot overflow; the `checked_*` +/// calls are kept as a defensive guard and are never expected to fail. +pub fn isqrt_u128(n: u128) -> u128 { + if n < 2 { + return n; + } + + let mut num = n; + let mut res: u128 = 0; + + // Highest power of four that is <= n. + let mut bit: u128 = 1u128 << 126; + while bit > num { + bit >>= 2; + } + + while bit != 0 { + // res < 2^64 (it holds the already-determined bits of the root) and + // bit <= 2^126, so the candidate fits in u128. + let candidate = res.checked_add(bit).expect("isqrt candidate overflow"); + if num >= candidate { + num = num.checked_sub(candidate).expect("isqrt remainder underflow"); + res = (res >> 1).checked_add(bit).expect("isqrt result overflow"); + } else { + res >>= 1; + } + bit >>= 2; + } + res +} + +/// Integer square root for signed 128-bit values. +/// +/// Returns `floor(sqrt(n))`, or `0` when `n <= 0`. The result of `i128::MAX` +/// is about `1.3e19` and always fits in `i128`. +pub fn isqrt_i128(n: i128) -> i128 { + if n <= 0 { + return 0; + } + // n > 0 here, so the cast to u128 is lossless. + let root = isqrt_u128(n as u128); + // root <= 2^63 for any positive i128, so the cast back is lossless. + root as i128 +} + +/// Checked quadratic cost of moving from `prev_votes` to `prev_votes + additional`. +/// +/// Computes `(prev + additional)^2 - prev^2` (the incremental cost of quadratic +/// voting, where a total of `V` votes costs `V^2` credits). Returns `None` if any +/// intermediate step overflows `i128`. +pub fn quadratic_cost(prev_votes: u64, additional: u64) -> Option { + let prev = i128::from(prev_votes); + let total = prev.checked_add(i128::from(additional))?; + let total_sq = total.checked_mul(total)?; + let prev_sq = prev.checked_mul(prev)?; + total_sq.checked_sub(prev_sq) +} diff --git a/contracts/quadratic_voting/src/test.rs b/contracts/quadratic_voting/src/test.rs index 5fe1fc1d3..b5a27d81e 100644 --- a/contracts/quadratic_voting/src/test.rs +++ b/contracts/quadratic_voting/src/test.rs @@ -193,3 +193,71 @@ fn property_terminal_vote_state_is_consistent() { client.vote(&user, &1, &7); assert_eq!(client.get_user_credits(&user), 100 - 49); } + +#[test] +fn isqrt_boundaries() { + assert_eq!(math::isqrt_u128(0), 0); + assert_eq!(math::isqrt_u128(1), 1); + assert_eq!(math::isqrt_u128(3), 1); + assert_eq!(math::isqrt_u128(4), 2); + assert_eq!(math::isqrt_u128(99), 9); + assert_eq!(math::isqrt_u128(100), 10); + assert_eq!(math::isqrt_u128(u128::MAX), u64::MAX as u128); + + assert_eq!(math::isqrt_i128(0), 0); + assert_eq!(math::isqrt_i128(1), 1); + assert_eq!(math::isqrt_i128(3), 1); + assert_eq!(math::isqrt_i128(4), 2); + assert_eq!(math::isqrt_i128(99), 9); + assert_eq!(math::isqrt_i128(100), 10); + assert_eq!(math::isqrt_i128(-1), 0); + assert_eq!(math::isqrt_i128(i128::MIN), 0); +} + +#[test] +fn isqrt_i128_max_does_not_panic_and_is_floor() { + let r = math::isqrt_i128(i128::MAX); + // r is floor(sqrt(MAX)): r^2 <= MAX < (r+1)^2. + assert!(r.checked_mul(r).is_some()); + assert!(r.checked_mul(r).unwrap() <= i128::MAX); + assert!((r + 1).checked_mul(r + 1).is_none()); +} + +#[test] +fn isqrt_matches_exact_for_perfect_squares() { + for k in 0u128..=2_000 { + assert_eq!(math::isqrt_u128(k * k), k); + if k > 0 { + assert_eq!(math::isqrt_u128(k * k - 1), k - 1); + } + } +} + +#[test] +fn quadratic_cost_values() { + assert_eq!(math::quadratic_cost(0, 0), Some(0)); + assert_eq!(math::quadratic_cost(0, 3), Some(9)); + assert_eq!(math::quadratic_cost(3, 2), Some(16)); // 25 - 9 + assert_eq!(math::quadratic_cost(3, 4), Some(40)); // 49 - 9 + assert_eq!(math::quadratic_cost(u32::MAX as u64, 1), Some( + (u32::MAX as i128 + 1).pow(2) - (u32::MAX as i128).pow(2) + )); +} + +#[test] +fn quadratic_cost_overflow_returns_none() { + assert_eq!(math::quadratic_cost(u64::MAX, u64::MAX), None); +} + +#[test] +fn vote_incremental_cost_is_quadratic() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup(&env); + let user = Address::generate(&env); + client.create_proposal(&user, &String::from_str(&env, "p")); + client.vote(&user, &1, &3); // cost 9 + client.vote(&user, &1, &2); // total 5 -> cost 25, incremental 16 + assert_eq!(client.get_user_credits(&user), 100 - 25); + assert_eq!(client.get_proposal(&1).votes_received, 5); +}