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
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)
Overview
CampaignTotals::increment(crates/tools/src/campaign_totals.rs line 21) accepts anyi128amount without sign or range validation. Callers like thevaultflow can therefore subtract from the running total by passing a negativeamount(subtraction by signed add is well-defined in Rust). The accounting model in this struct is supposed to mirror monetary inflows only.Evidence
Compare with
storage_increment_total_raisedin the smart contract, which rejects negatives with a typed panic:Impact
(campaign_id, asset)totals negative without a loud error — downstreamget()callers see negative numbers masquerading as totals.Recommended Approach
Match the smart-contract pattern: panic / return
Erron non-positive amounts: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::incrementrejectsamount <= 0with a typed error, not a silent acceptamount = i128::MAX - entry) produce a typed error rather than wrappingAffected Files
crates/tools/src/campaign_totals.rscrates/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.rsorcrates/tools/src/campaign_totals.rs::mod tests(new negative test)