Skip to content

[LOW] CampaignTotals::increment accepts negative amount — should reject like storage_increment_total_raised does #50

Description

@Alqku

Overview

CampaignTotals::increment (crates/tools/src/campaign_totals.rs line 21) accepts any i128 amount without sign or range validation. Callers like the vault flow can therefore subtract from the running total by passing a negative amount (subtraction by signed add is well-defined in Rust). The accounting model in this struct is supposed to mirror monetary inflows only.

Evidence

// crates/tools/src/campaign_totals.rs
pub fn increment(&mut self, campaign_id: u64, asset: &str, amount: i128) -> i128 {
    let entry = self.asset_totals.entry((campaign_id, asset.to_string())).or_insert(0);
    *entry += amount;          // accepts any i128, including negative
    *entry
}

Compare with storage_increment_total_raised in the smart contract, which rejects negatives with a typed panic:

pub fn storage_increment_total_raised(env: &Env, delta: i128) -> i128 {
    if delta <= 0 {
        panic_with_error!(env, Error::InvalidAmount);
    }
    ...
}

Impact

  • A bookkeeping bug in a custom integration script can drive (campaign_id, asset) totals negative without a loud error — downstream get() callers see negative numbers masquerading as totals.
  • Dashboards plotting the cumulative sum interpret this as "~(...) raised", confusing for users.
  • The struct is a side-channel model used by analytics pipelines; an inconsistent semantic between "amount added" (might be negative) and "amount raised" (always positive) defeats the purpose of having both an on-chain authoritative counter and an off-chain mirror.

Recommended Approach

Match the smart-contract pattern: panic / return Err on non-positive amounts:

pub fn increment(&mut self, campaign_id: u64, asset: &str, amount: i128) -> Result<i128> {
    if amount <= 0 {
        return Err(anyhow!("CampaignTotals::increment requires positive amount; got {}", amount));
    }
    let entry = self.asset_totals.entry((campaign_id, asset.to_string())).or_insert(0);
    *entry = entry.checked_add(amount).ok_or_else(|| anyhow!("overflow in CampaignTotals"))?;
    Ok(*entry)
}

If #[must_use] semantics and the existing call sites need to stay infallible, raise an error variant on the perimeter and propagate via a typed error, but the primary need is loud failure on non-positive amounts.

Acceptance Criteria

  • CampaignTotals::increment rejects amount <= 0 with a typed error, not a silent accept
  • Saturated additions (amount = i128::MAX - entry) produce a typed error rather than wrapping
  • Existing tests adjusted to pass positive amounts and to assert the new error path
  • A negative test passes an explicit negative amount and asserts the rejection

Affected Files

  • crates/tools/src/campaign_totals.rs
  • crates/tools/src/main.rs (call sites — none in current code that I'm aware of, so this is purely API hardening)
  • crates/tools/tests/integration_test.rs or crates/tools/src/campaign_totals.rs::mod tests (new negative test)

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignbugSomething isn't workingcorrectnessIncorrect behavior or state bug

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions