diff --git a/contracts/quadratic_funding/src/lib.rs b/contracts/quadratic_funding/src/lib.rs
index 95138fe6..cbc19ccb 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 bb58d7f7..ea385d35 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 fbc05bcd..1fb52915 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 00000000..49f4b3e0
--- /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 5fe1fc1d..b5a27d81 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);
+}