Overview
release_milestone_multi_asset (multi_asset_release.rs lines 188-189) clamps the decremented per-asset raised counter using .checked_sub(...).unwrap_or(0).max(0) — so if for any reason the on-contract asset_raised ledger is less than the amount being subtracted (rounding, off-by-one in a deposition event, an integer-truncation artifact in compute_asset_release, or a future bug elsewhere), the subtraction silently collapses to 0 instead of producing a typed panic. The surrounding pattern in the same function is "panic with Error::Overflow" (see the function body around line 193), so the silent clamp is inconsistent with the typed-error contract and hides an invariant violation.
Evidence
// campaign/src/multi_asset_release.rs
storage_set_asset_raised(env, &token_address, asset_raised);
let new_asset_raised = asset_raised
.checked_sub(clamped_release)
.unwrap_or(0) // <-- silent on underflow
.max(0); // <-- second clamp, same intent
Compare with the release_amount calculations just above:
let milestone_release = milestone.target_amount
.checked_sub(milestone.released_amount)
.unwrap_or_else(|| panic_with_error!(env, Error::MilestoneReleasedExceedsTarget));
The release_amount path underflows to a typed panic; the asset_raised decrement underflows to 0.
Impact
- A donor / sub-account that donates extra funds directly (outside the canonical
donate() path, e.g. a Stellar fee refund or a forwarder) could push the contract's actual STROOP balance for an asset above asset_raised, then a release that clamped_release = asset_balance (not asset_raised) would underflow the subtraction. The ledger becomes permanently out of sync with reality.
- Subsequent refund calculations read
storage_get_asset_raised; if it dropped to 0 prematurely, pro-rata refund math starts producing 0s for legitimate donors.
- Indexers that compute
asset_raised == contract_balance invariants will report a non-existent mismatch.
Recommended Approach
Replace the silent clamp with a typed panic that mirrors the rest of the function:
let new_asset_raised = asset_raised
.checked_sub(clamped_release)
.unwrap_or_else(|| panic_with_error!(env, Error::Overflow));
if new_asset_raised < 0 {
panic_with_error!(env, Error::Overflow);
}
storage_set_asset_raised(env, &token_address, new_asset_raised);
Or add a new Error::LedgerUnderflow variant (e.g. = 81 like the existing numbered scheme) so the cause is unmistakable in logs. The invariant is: asset_raised >= clamped_release should always hold by construction; if it doesn't, surface the bug loudly rather than panicking under "overflow" attribution.
Acceptance Criteria
Affected Files
campaign/src/multi_asset_release.rs
campaign/src/types.rs (if a new Error::LedgerUnderflow variant is added)
campaign/src/test/release_milestone_tests.rs (or multi_asset_release.rs test mod)
Overview
release_milestone_multi_asset(multi_asset_release.rs lines 188-189) clamps the decremented per-asset raised counter using.checked_sub(...).unwrap_or(0).max(0)— so if for any reason the on-contractasset_raisedledger is less than the amount being subtracted (rounding, off-by-one in a deposition event, an integer-truncation artifact incompute_asset_release, or a future bug elsewhere), the subtraction silently collapses to 0 instead of producing a typed panic. The surrounding pattern in the same function is "panic withError::Overflow" (see the function body around line 193), so the silent clamp is inconsistent with the typed-error contract and hides an invariant violation.Evidence
Compare with the
release_amountcalculations just above:The
release_amountpath underflows to a typed panic; theasset_raiseddecrement underflows to 0.Impact
donate()path, e.g. a Stellar fee refund or a forwarder) could push the contract's actual STROOP balance for an asset aboveasset_raised, then a release thatclamped_release = asset_balance(notasset_raised) would underflow the subtraction. The ledger becomes permanently out of sync with reality.storage_get_asset_raised; if it dropped to 0 prematurely, pro-rata refund math starts producing 0s for legitimate donors.asset_raised == contract_balanceinvariants will report a non-existent mismatch.Recommended Approach
Replace the silent clamp with a typed panic that mirrors the rest of the function:
Or add a new
Error::LedgerUnderflowvariant (e.g. = 81 like the existing numbered scheme) so the cause is unmistakable in logs. The invariant is:asset_raised >= clamped_releaseshould always hold by construction; if it doesn't, surface the bug loudly rather than panicking under "overflow" attribution.Acceptance Criteria
release_milestone_multi_assetdoes NOT call.unwrap_or(0).max(0)to silence underflow onasset_raiseddecrementstorage_set_total_raisedafter the loop) are reconciled consistently with the same approachAffected Files
campaign/src/multi_asset_release.rscampaign/src/types.rs(if a newError::LedgerUnderflowvariant is added)campaign/src/test/release_milestone_tests.rs(ormulti_asset_release.rstest mod)