Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr-test-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ jobs:
- name: Install dependencies
run: npm ci --include=optional

- name: Install Rollup Native Binding
run: npm install @rollup/rollup-linux-x64-gnu --no-save

- name: Prepare database schema
run: |
npx prisma generate --schema=prisma/schema.prisma
Expand Down
24 changes: 19 additions & 5 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ impl StreamContract {
///
/// Excludes any time the stream was paused. If the stream is currently
/// paused, accrual stops at `paused_at`.
///
/// # Overflow Protection
/// - Uses `checked_mul` for rate_per_second * elapsed_seconds multiplication
/// - Caps at stream.deposited_amount if overflow would occur
/// - Uses `checked_sub` for deposited - already_withdrawn calculation
/// - Overflow boundary: i128::MAX (~1.7e19) for both rate and duration
fn calculate_claimable(stream: &Stream, now: u64) -> i128 {
let effective_now = if stream.paused {
stream.paused_at.unwrap_or(stream.last_update_time)
Expand All @@ -261,13 +267,21 @@ impl StreamContract {
};
let elapsed = effective_now.saturating_sub(stream.last_update_time);

let streamed = (elapsed as i128)
.checked_mul(stream.rate_per_second)
.unwrap_or(i128::MAX);
// Use checked_mul to prevent overflow when multiplying rate * elapsed
// If overflow would occur, cap at deposited_amount (full deposit)
let streamed = match (elapsed as i128).checked_mul(stream.rate_per_second) {
Some(result) => result,
None => return stream.deposited_amount, // Overflow: cap at full deposit
};

let remaining = stream
// Use checked_sub for deposited - withdrawn calculation
let remaining = match stream
.deposited_amount
.saturating_sub(stream.withdrawn_amount);
.checked_sub(stream.withdrawn_amount)
{
Some(result) => result,
None => 0, // Underflow: already withdrawn more than deposited
};

streamed.min(remaining)
}
Expand Down
37 changes: 37 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,43 @@ fn test_cancel_stream_after_partial_withdrawal() {
assert_eq!(contract_balance_after, 0);
}

#[test]
fn test_claimable_max_i128_rate_overflow() {
let env = Env::default();
env.mock_all_auths();

let (token, _) = create_token(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
mint(&env, &token, &sender, i128::MAX);

let client = create_contract(&env);

// Create stream with near-max i128 rate
let max_rate = i128::MAX / 2;
let stream_id = client.create_stream(&sender, &recipient, &token, &1_000, &1);

// Manually set rate to near-max i128 to test overflow protection
let mut stream = client.get_stream(&stream_id).unwrap();
stream.rate_per_second = max_rate;
env.as_contract(&client.address, || {
env.storage().persistent().set(&types::DataKey::Stream(stream_id), &stream);
});

// Advance time by a large amount that would cause overflow
env.ledger().with_mut(|l| {
l.timestamp += 1_000_000_000;
});

// get_claimable_amount should cap at deposited_amount, not overflow
let claimable = client.get_claimable_amount(&stream_id).unwrap();
assert_eq!(claimable, 1_000); // Should cap at deposited amount

// Withdraw should work correctly without overflow
let withdrawn = client.withdraw(&recipient, &stream_id);
assert_eq!(withdrawn, 1_000);
}

// ─── #232 create_stream edge cases ───────────────────────────────────────────

#[test]
Expand Down
Loading
Loading