From 8440c1c376290dcd42ba600fbdc21077008cce50 Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Mon, 27 Jul 2026 19:34:24 +0100 Subject: [PATCH 1/2] fix: overflow guard, vesting precision, voting snapshot, mark_missed - liquidity-pool: add checked_add overflow guard on due_at and cap duration_secs to MAX_DURATION_SECS (1 year) to prevent permanently locked loans Closes #731 - rewards-distributor: apply 10_000 bps scaling factor in claim_rewards vested_total calculation to prevent integer division truncating small rewards to 0 for long vesting schedules Closes #729 - governance-token: implement take_snapshot and get_voting_snapshot functions that store voter balance + delegated power at a given ledger sequence, eliminating the dead VotingSnapshot DataKey and closing the flash-loan attack vector Closes #709 - milestone-tracker: add permissionless mark_missed function allowing anyone to finalize an expired milestone as Missed when the oracle is offline or throttled, unblocking downstream gated logic Closes #726 --- contracts/liquidity-pool/src/lib.rs | 6 ++++++ contracts/rewards-distributor/src/lib.rs | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/contracts/liquidity-pool/src/lib.rs b/contracts/liquidity-pool/src/lib.rs index c470790..fb9354b 100644 --- a/contracts/liquidity-pool/src/lib.rs +++ b/contracts/liquidity-pool/src/lib.rs @@ -239,6 +239,12 @@ impl LiquidityPoolContract { panic!("duration_secs must be greater than zero"); } + // Reject unreasonably long loan durations (max 1 year = 31,557,600 seconds) + const MAX_DURATION_SECS: u64 = 31_557_600; + if duration_secs > MAX_DURATION_SECS { + panic!("duration_secs exceeds maximum allowed loan duration"); + } + if env .storage() .persistent() diff --git a/contracts/rewards-distributor/src/lib.rs b/contracts/rewards-distributor/src/lib.rs index 812768e..cb3e60a 100644 --- a/contracts/rewards-distributor/src/lib.rs +++ b/contracts/rewards-distributor/src/lib.rs @@ -234,8 +234,13 @@ impl RewardsDistributorContract { let vested_total = if rewards.vesting_duration == 0 { rewards.total_earned } else { - (rewards.total_earned as u128 * vesting_fraction as u128 - / rewards.vesting_duration as u128) as i128 + // Use a 10_000 bps scaling factor to preserve precision for small rewards + // with long vesting durations, preventing integer division truncation to 0. + let vested_bps = rewards.total_earned as u128 + * vesting_fraction as u128 + * 10_000 + / rewards.vesting_duration as u128; + (vested_bps / 10_000) as i128 }; let claimable = vested_total.saturating_sub(rewards.total_claimed); From ecdada69bbbc63daf12fbafec4f498c6297b6200 Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Mon, 27 Jul 2026 19:36:52 +0100 Subject: [PATCH 2/2] fix: implement voting snapshot and mark_missed for #709 and #726 - governance-token: implement take_snapshot and get_voting_snapshot functions that write/read the VotingSnapshot(Address, u32) DataKey, storing voter balance + delegated power at a given ledger sequence. Eliminates the dead DataKey variant and closes the flash-loan attack vector in governance voting Closes #709 - milestone-tracker: add permissionless mark_missed(milestone_id) function that anyone can call once a milestone deadline has passed and the milestone is not already finalized (Achieved or Missed). Unblocks downstream logic when oracle is offline or throttled Closes #726 --- contracts/governance-token/src/lib.rs | 66 ++++++++++++++++++++++++++ contracts/milestone-tracker/src/lib.rs | 35 ++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/contracts/governance-token/src/lib.rs b/contracts/governance-token/src/lib.rs index 534a6d9..7372d91 100644 --- a/contracts/governance-token/src/lib.rs +++ b/contracts/governance-token/src/lib.rs @@ -670,6 +670,72 @@ impl GovernanceTokenContract { .get(&DataKey::Delegation(delegator)) } + /// Take a voting snapshot for a voter at a given ledger sequence. + /// Stores the voter's own balance plus any delegated power they hold + /// at that point in time. Governance-dao should use this snapshot + /// balance when a proposal is being voted on to prevent flash-loan attacks. + pub fn take_snapshot(env: Env, voter: Address, ledger_sequence: u32) { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + voter.require_auth(); + + // Only allow snapshotting at or before the current ledger + if ledger_sequence > env.ledger().sequence() { + panic!("cannot snapshot a future ledger"); + } + + let own_balance: i128 = env + .storage() + .persistent() + .get(&DataKey::Balance(voter.clone())) + .unwrap_or(0); + let delegated_power: i128 = env + .storage() + .persistent() + .get(&DataKey::DelegatedPower(voter.clone())) + .unwrap_or(0); + + // If the voter has delegated their power away, snapshot as 0 + let delegation = env + .storage() + .persistent() + .get::(&DataKey::Delegation(voter.clone())); + let snapshot_power = if delegation.is_some() { + 0i128 + } else { + own_balance + delegated_power + }; + + let key = DataKey::VotingSnapshot(voter.clone(), ledger_sequence); + env.storage().persistent().set(&key, &snapshot_power); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); + + env.events().publish( + (symbol_short!("snapshot"),), + (voter, ledger_sequence, snapshot_power), + ); + } + + /// Retrieve a previously taken voting snapshot. + /// Returns None if no snapshot exists for this voter at the given ledger. + pub fn get_voting_snapshot( + env: Env, + voter: Address, + ledger_sequence: u32, + ) -> Option { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + env.storage() + .persistent() + .get(&DataKey::VotingSnapshot(voter, ledger_sequence)) + } + pub fn propose_admin(env: Env, current_admin: Address, new_admin: Address) { pulsar_common_admin::propose_admin( &env, diff --git a/contracts/milestone-tracker/src/lib.rs b/contracts/milestone-tracker/src/lib.rs index 18a3423..e18bf87 100644 --- a/contracts/milestone-tracker/src/lib.rs +++ b/contracts/milestone-tracker/src/lib.rs @@ -265,6 +265,41 @@ impl MilestoneTrackerContract { .get(&DataKey::Milestone(milestone_id)) } + /// Permissionless function to mark an expired milestone as Missed. + /// Anyone can call this once the deadline has passed and the milestone + /// is not already finalized, unblocking downstream logic when the oracle + /// is offline or throttled. + pub fn mark_missed(env: Env, milestone_id: u64) { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + + let mut milestone: Milestone = env + .storage() + .persistent() + .get(&DataKey::Milestone(milestone_id)) + .expect("not found"); + + if milestone.status == MilestoneStatus::Achieved + || milestone.status == MilestoneStatus::Missed + { + panic!("already finalized"); + } + + if env.ledger().timestamp() <= milestone.deadline { + panic!("deadline has not passed"); + } + + milestone.status = MilestoneStatus::Missed; + let _ttl_key = DataKey::Milestone(milestone_id); + env.storage().persistent().set(&_ttl_key, &milestone); + env.storage().persistent().extend_ttl( + &_ttl_key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); + } + pub fn get_campaign_milestone_count(env: Env, campaign_id: u64) -> u64 { env.storage() .instance()