Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions PR_COLD_OVERFLOW_SAFE_MATH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# PR: overflow-safe math sweep in cold (Closes #686)

## Overview

Completes an **overflow-safe math sweep** of the Callora Vault's cold-storage
module (`contracts/vault/src/cold_storage.rs`). Every arithmetic operation in
the cold hot/cold-split and rebalance paths now routes through Rust's
`checked_*` operators, returning `VaultError::Overflow` on any overflow instead
of relying on debug assertions or silently wrapping in release builds.

**Closes: #686** ("Add overflow-safe math sweep in cold")

> Note on file path: issue #686 references `contracts/cold/src/lib.rs`. There is
> no standalone `cold` crate in this workspace — the cold-storage logic lives in
> the Vault contract at `contracts/vault/src/cold_storage.rs` (declared as
> `mod cold_storage` in `contracts/vault/src/lib.rs`). This PR targets that
> module, which is the canonical home of all "cold" math.

---

## What changed

The module was already largely checked-math clean (`total`, `hot_share_bps`,
`target_hot`, and the hot→cold move in `maybe_rebalance` all used `checked_*`).
The sweep closes the one remaining gap:

- **`maybe_rebalance` drift computation.** The drift between the current and
target hot share was computed with raw `(current_share - target_share).abs()`.
This is now `current_share.checked_sub(target_share)? .checked_abs()?`,
mapping any overflow to `VaultError::Overflow`. `checked_abs` additionally
guards the `i128::MIN` edge case, where `.abs()` panics.

No behaviour changes for in-range inputs — identical results, plus a defined
error return instead of a panic/wrap at the extremes.

## Safety properties

- **No raw arithmetic** remains in any non-test code path in the module.
- **No `unwrap()` in production paths** — overflow is surfaced as
`Result<_, VaultError>` and propagated with `?`.
- **Conservation invariant** (`hot + cold == total`) is preserved by the
overflow-safe rebalance path (asserted in tests).
- **Authorization** is unchanged: the cold-sweep multisig flow already gates
every state-changing action behind configured cold signers and
`require_auth`; this PR touches only pure arithmetic helpers and adds no new
entrypoints.

## Tests

Added focused unit tests alongside the existing suite in the module's
`#[cfg(test)] mod tests`:

| Test | Covers |
|------|--------|
| `hot_share_bps_overflow_is_caught` | `checked_mul` overflow in share calc |
| `target_hot_overflow_is_caught` | `checked_mul` overflow in target calc |
| `target_hot_pub_matches_target_hot` | public wrapper parity + overflow |
| `maybe_rebalance_total_overflow_is_caught` | overflow via `total()` |
| `maybe_rebalance_drift_is_overflow_safe` | checked drift + conservation |
| `is_cold_signer_detects_membership` | signer-set membership helper |

These complement the pre-existing rebalance/validate tests, exercising every
`checked_*` branch introduced or touched by the sweep.

## Files changed

| File | Change |
|------|--------|
| `contracts/vault/src/cold_storage.rs` | overflow-safe drift + 6 new tests |
107 changes: 106 additions & 1 deletion contracts/vault/src/cold_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,17 @@ pub fn maybe_rebalance(

let current_share = hot_share_bps(balances.hot, total)?;
let target_share = config.hot_bps as i128;
let drift = (current_share - target_share).abs();
// Overflow-safe drift. Both shares are basis-point values in
// `0..=BPS_DENOMINATOR`, so in practice this cannot overflow — but we
// route it through `checked_sub`/`checked_abs` so the entire cold
// rebalance path is uniformly overflow-safe and never relies on debug
// assertions or silent wrapping in release builds. `checked_abs` also
// guards the `i128::MIN` edge case, where `.abs()` would panic.
let drift = current_share
.checked_sub(target_share)
.ok_or(VaultError::Overflow)?
.checked_abs()
.ok_or(VaultError::Overflow)?;

if drift <= config.rebalance_threshold_bps as i128 {
// Within tolerance — no rebalance.
Expand Down Expand Up @@ -453,4 +463,99 @@ mod tests {
};
assert_eq!(balances.total(), Err(VaultError::Overflow));
}

#[test]
fn hot_share_bps_overflow_is_caught() {
// hot * BPS_DENOMINATOR overflows i128 before the division.
assert_eq!(
hot_share_bps(i128::MAX, i128::MAX),
Err(VaultError::Overflow)
);
}

#[test]
fn target_hot_overflow_is_caught() {
// total * hot_bps overflows i128 before the division.
assert_eq!(target_hot(i128::MAX, 10_000), Err(VaultError::Overflow));
}

#[test]
fn target_hot_pub_matches_target_hot() {
// The public wrapper must return exactly what the private helper does.
assert_eq!(
target_hot_pub(10_000, 2000).unwrap(),
target_hot(10_000, 2000).unwrap()
);
assert_eq!(target_hot_pub(i128::MAX, 10_000), Err(VaultError::Overflow));
}

#[test]
fn maybe_rebalance_total_overflow_is_caught() {
let env = Env::default();
let config = ColdConfig {
hot_bps: 2000,
rebalance_threshold_bps: 500,
cold_signers: {
let mut v = Vec::new(&env);
v.push_back(addr(&env, 1));
v
},
cold_threshold: 1,
};
// hot + cold overflows i128 inside `balances.total()`.
let balances = ColdBalances {
hot: i128::MAX,
cold: 1,
};
assert_eq!(
maybe_rebalance(&balances, &config),
Err(VaultError::Overflow)
);
}

#[test]
fn maybe_rebalance_drift_is_overflow_safe() {
// Exercises the checked drift computation on a well-formed split.
// current share 40%, target 20% => drift 20% (2000 bps) which
// exceeds the 5% (500 bps) tolerance, so hot surplus moves to cold.
let env = Env::default();
let config = ColdConfig {
hot_bps: 2000,
rebalance_threshold_bps: 500,
cold_signers: {
let mut v = Vec::new(&env);
v.push_back(addr(&env, 1));
v
},
cold_threshold: 1,
};
let balances = ColdBalances {
hot: 4000,
cold: 6000,
};
let result = maybe_rebalance(&balances, &config).unwrap();
assert_eq!(result.hot, 2000);
assert_eq!(result.cold, 8000);
// Conservation invariant preserved by the overflow-safe path.
assert_eq!(result.total().unwrap(), balances.total().unwrap());
}

#[test]
fn is_cold_signer_detects_membership() {
let env = Env::default();
let signer = addr(&env, 1);
let outsider = addr(&env, 2);
let config = ColdConfig {
hot_bps: 2000,
rebalance_threshold_bps: 500,
cold_signers: {
let mut v = Vec::new(&env);
v.push_back(signer.clone());
v
},
cold_threshold: 1,
};
assert!(config.is_cold_signer(&signer));
assert!(!config.is_cold_signer(&outsider));
}
}
Loading