Skip to content

fix(contracts): claimable() returns 0 after cancel and auto-settle (#591) - #655

Open
adesuwa-tech wants to merge 2 commits into
ritik4ever:mainfrom
adesuwa-tech:fix/591-claimable-after-cancel
Open

fix(contracts): claimable() returns 0 after cancel and auto-settle (#591)#655
adesuwa-tech wants to merge 2 commits into
ritik4ever:mainfrom
adesuwa-tech:fix/591-claimable-after-cancel

Conversation

@adesuwa-tech

@adesuwa-tech adesuwa-tech commented Jul 24, 2026

Copy link
Copy Markdown

Closes #591

After cancel() the contract atomically settles the stream: the unclaimed vested remainder is transferred to the recipient, the unvested portion is refunded to the sender, and the stream is marked canceled with end_time/total_amount bounded to the cancel moment. claimable() and get_claimable_batch() short-circuit to 0 for canceled streams. saturating_sub keeps the cancel arithmetic defensive. The new test_claimable_returns_zero_after_cancel covers the acceptance criterion. Updated existing tests assert the new atomic semantics; idempotent double-cancel still works.

Summary by CodeRabbit

  • Bug Fixes

    • Canceled streams now immediately settle vested funds for recipients and refund unvested funds to senders.
    • Claimable balances for canceled streams now return zero, including batch queries.
    • Claims attempted after cancellation are rejected.
    • Clawbacks after cancellation no longer withdraw additional funds.
  • Tests

    • Expanded coverage for cancellation settlement, token conservation, batch queries, and post-cancellation claims.

…itik4ever#591)

After cancel() the contract now atomically finalises the stream:
  * Send the unclaimed vested remainder to the recipient.
  * Refund the unvested portion to the sender.
  * Mark canceled, bound end_time and total_amount to the cancel moment.

This makes claimable(stream_id, at_time) return 0 for any canceled stream
(per the acceptance criterion) and lets claim() panic deterministically on
canceled streams because the contract no longer owes anything. Previously
claimable kept returning a non-zero amount based on elapsed time which left
funds stranded inside the contract.

Saturating_sub is used on the cancel arithmetic for defensiveness.

Tests:
- Updated test_cancel_after_partial_claim_*, test_cancel_recipient_*,
  test_split_stream_claim_and_cancel_work_per_substream,
  test_clawback_after_canceled_stream_* to match the new atomic semantics.
- Refactored test_claim_on_canceled_stream (no longer relies on manual
  claim after cancel since cancel now auto-settles).
- Added test_claimable_returns_zero_after_cancel (the AC).
- Added test_claim_after_cancel_panics (lock down the panic path).

Closes ritik4ever#591
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the ritik4ever's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 24, 2026

Copy link
Copy Markdown

@adesuwa-tech Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Canceled streams now settle vested funds to recipients, refund unvested funds to senders, and report zero claimable amounts. Tests and snapshots cover post-cancel queries, failed claims, split streams, refunds, and clawback behavior.

Changes

Canceled Stream Settlement

Layer / File(s) Summary
Cancel settlement and stream state
contracts/src/lib.rs
cancel computes vested payouts and refunds, updates canceled-stream state, and transfers both amounts.
Post-cancel claimable queries
contracts/src/lib.rs
claimable and get_claimable_batch return 0 for canceled streams while active streams retain normal calculations.
Cancel behavior tests and snapshots
contracts/src/test.rs, contracts/test_snapshots/test/*
Tests and snapshots validate settlement, refunds, zero claimable values, failed claims, split streams, and clawback behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Sender
  participant StreamContract
  participant TokenClient
  participant Recipient
  Sender->>StreamContract: cancel(stream_id)
  StreamContract->>TokenClient: transfer vested payout
  TokenClient->>Recipient: recipient receives vested amount
  StreamContract->>TokenClient: transfer unvested refund
  TokenClient->>Sender: sender receives refund
  Sender->>StreamContract: claimable(stream_id, at_time)
  StreamContract-->>Sender: return 0
Loading

Possibly related PRs

Suggested reviewers: wolfyres, jamesvictor-o, testersweb

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds atomic cancellation settlement, including transfers, refunds, schedule bounding, and total amount changes beyond issue #591. Separate the atomic settlement changes into a related issue or provide linked requirements that justify this expanded cancellation scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main changes: zero claimability after cancellation and automatic settlement.
Linked Issues check ✅ Passed The PR satisfies issue #591 by returning zero for canceled streams and adding post-cancel claimability tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
contracts/src/lib.rs (1)

601-634: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Guard resume_stream against canceled streams.

cancel() does not clear paused, and claim() returns 0 for canceled streams, but resume_stream() still persists schedule changes when !stream.paused is true. Add if stream.canceled { panic!("stream canceled"); } before resuming, matching pause_stream().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/lib.rs` around lines 601 - 634, Add a canceled-stream guard in
resume_stream immediately after loading or validating the stream, before the
paused-state check and any schedule updates: if stream.canceled is true, panic
with "stream canceled". Match the existing guard behavior in pause_stream while
leaving non-canceled resume handling unchanged.
🧹 Nitpick comments (1)
contracts/src/lib.rs (1)

399-427: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate canceled/vested-clamp logic between claimable() and get_claimable_batch().

The if stream.canceled { 0 } else { vested - claimed, clamp } branch here duplicates the body of claimable() (lines 389-397). This is exactly the class of divergence that caused issue #591 (one path was updated, the other wasn't); extracting a shared helper removes the risk of the two drifting again.

♻️ Suggested extraction
+fn claimable_amount(stream: &Stream, at_time: u64) -> i128 {
+    if stream.canceled {
+        return 0;
+    }
+    let vested = vested_amount(stream, at_time);
+    let claimable = vested - stream.claimed_amount;
+    if claimable < 0 { 0 } else { claimable }
+}
+
 pub fn claimable(env: Env, stream_id: u64, at_time: u64) -> i128 {
     let stream = read_stream(&env, stream_id);
-    if stream.canceled {
-        return 0;
-    }
-    let vested = vested_amount(&stream, at_time);
-    let claimable = vested - stream.claimed_amount;
-    if claimable < 0 { 0 } else { claimable }
+    claimable_amount(&stream, at_time)
 }

 pub fn get_claimable_batch(env: Env, stream_ids: Vec<u64>, at_time: u64) -> Map<u64, i128> {
     ...
             let amount = match stream_opt {
-                Some(stream) => {
-                    if stream.canceled {
-                        0
-                    } else {
-                        let vested = vested_amount(&stream, at_time);
-                        let claimable = vested - stream.claimed_amount;
-                        if claimable < 0 { 0 } else { claimable }
-                    }
-                }
+                Some(stream) => claimable_amount(&stream, at_time),
                 None => 0,
             };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/lib.rs` around lines 399 - 427, Extract the shared
canceled/vested-amount calculation from claimable() into a helper, then have
both claimable() and get_claimable_batch() call it. Preserve the existing zero
result for canceled streams, missing streams, and negative claimable amounts
while removing the duplicated branch from get_claimable_batch().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/src/lib.rs`:
- Around line 500-516: Update the cancellation logic in the function containing
vested_amount, recipient_payout, and sender_refund so the persisted end-time
bound uses the effective vesting cutoff represented by vested, rather than raw
ledger timestamp now. Ensure paused-then-canceled streams retain a schedule
consistent with their frozen vested amount, while preserving the existing
start-time lower bound and normal active-stream behavior.

---

Outside diff comments:
In `@contracts/src/lib.rs`:
- Around line 601-634: Add a canceled-stream guard in resume_stream immediately
after loading or validating the stream, before the paused-state check and any
schedule updates: if stream.canceled is true, panic with "stream canceled".
Match the existing guard behavior in pause_stream while leaving non-canceled
resume handling unchanged.

---

Nitpick comments:
In `@contracts/src/lib.rs`:
- Around line 399-427: Extract the shared canceled/vested-amount calculation
from claimable() into a helper, then have both claimable() and
get_claimable_batch() call it. Preserve the existing zero result for canceled
streams, missing streams, and negative claimable amounts while removing the
duplicated branch from get_claimable_batch().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 537d2f02-6567-4779-8ef6-a134156c8fd7

📥 Commits

Reviewing files that changed from the base of the PR and between b3d32c1 and 97b0bab.

⛔ Files ignored due to path filters (1)
  • contracts/src/snapshots/stellar_stream__test__stream_cancel_after_partial_claim.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • contracts/src/lib.rs
  • contracts/src/test.rs
  • contracts/test_snapshots/test/test_claim_after_cancel_panics.1.json
  • contracts/test_snapshots/test/test_claimable_returns_zero_after_cancel.1.json

Comment thread contracts/src/lib.rs
Comment on lines 500 to +516
let now = env.ledger().timestamp();
stream.canceled = true;

let vested = vested_amount(&stream, now);
let sender_refund = stream.total_amount - vested;

// `saturating_sub` keeps `recipient_payout` non-negative if a caller has
// somehow claimed more than is vested (defensive — the regular `claim`
// path already prevents this, but cancel must still be safe).
let recipient_payout = vested.saturating_sub(stream.claimed_amount);
let sender_refund = stream.total_amount.saturating_sub(vested);

// Mark canceled and bound the stream's vesting schedule to the
// cancel moment so any future claimable() query is bounded by `vested`.
stream.canceled = true;
let min_end = if now > stream.start_time { now } else { stream.start_time };
if min_end < stream.end_time {
stream.end_time = min_end;
stream.total_amount = vested;
}
stream.total_amount = vested;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

min_end uses wall-clock now, not the effective (paused) vesting cutoff — corrupts the persisted schedule and can make clawback() return a negative amount for paused-then-canceled streams.

vested correctly freezes at pause_started_at when the stream is paused (per vested_amount), so recipient_payout/sender_refund are computed correctly. But min_end is derived from the raw now, not that same effective time. Example: stream 0–1000/total 1000, paused at t=300, canceled at t=800 → vested=300 (correct), but persisted end_time=800, total_amount=300. That’s an internally inconsistent linear schedule (300 vested over 800, not over 300).

This isn’t just cosmetic: clawback() (unchanged) doesn’t check stream.canceled and recomputes vested_amount directly on the persisted stream. With the inconsistent fields above, vested_amount recomputes to 112 (not 300), so unclaimed_vested = 112 - claimed_amount(300) = -188, and clawback can return a negative actual_clawback to the caller (no transfer occurs, but the reported clawback amount is wrong).

claimable()/get_claimable_batch() are unaffected since they short-circuit on canceled before touching vested_amount, so there's no direct fund-safety issue — but the stored schedule and clawback()'s return value are incorrect for this reachable pause→cancel ordering (nothing prevents cancel while paused).

🐛 Suggested fix — bound by the effective (paused-aware) time
         let now = env.ledger().timestamp();
 
         let vested = vested_amount(&stream, now);
         let recipient_payout = vested.saturating_sub(stream.claimed_amount);
         let sender_refund = stream.total_amount.saturating_sub(vested);
 
         stream.canceled = true;
-        let min_end = if now > stream.start_time { now } else { stream.start_time };
+        // Keep the persisted schedule consistent with the same instant used
+        // to freeze `vested` above (pause_started_at when paused).
+        let effective_now = if stream.paused {
+            stream.pause_started_at.unwrap_or(now)
+        } else {
+            now
+        };
+        let min_end = if effective_now > stream.start_time { effective_now } else { stream.start_time };
         if min_end < stream.end_time {
             stream.end_time = min_end;
         }
         stream.total_amount = vested;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let now = env.ledger().timestamp();
stream.canceled = true;
let vested = vested_amount(&stream, now);
let sender_refund = stream.total_amount - vested;
// `saturating_sub` keeps `recipient_payout` non-negative if a caller has
// somehow claimed more than is vested (defensive — the regular `claim`
// path already prevents this, but cancel must still be safe).
let recipient_payout = vested.saturating_sub(stream.claimed_amount);
let sender_refund = stream.total_amount.saturating_sub(vested);
// Mark canceled and bound the stream's vesting schedule to the
// cancel moment so any future claimable() query is bounded by `vested`.
stream.canceled = true;
let min_end = if now > stream.start_time { now } else { stream.start_time };
if min_end < stream.end_time {
stream.end_time = min_end;
stream.total_amount = vested;
}
stream.total_amount = vested;
let now = env.ledger().timestamp();
let vested = vested_amount(&stream, now);
// `saturating_sub` keeps `recipient_payout` non-negative if a caller has
// somehow claimed more than is vested (defensive — the regular `claim`
// path already prevents this, but cancel must still be safe).
let recipient_payout = vested.saturating_sub(stream.claimed_amount);
let sender_refund = stream.total_amount.saturating_sub(vested);
// Mark canceled and bound the stream's vesting schedule to the
// cancel moment so any future claimable() query is bounded by `vested`.
stream.canceled = true;
// Keep the persisted schedule consistent with the same instant used
// to freeze `vested` above (pause_started_at when paused).
let effective_now = if stream.paused {
stream.pause_started_at.unwrap_or(now)
} else {
now
};
let min_end = if effective_now > stream.start_time { effective_now } else { stream.start_time };
if min_end < stream.end_time {
stream.end_time = min_end;
}
stream.total_amount = vested;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/lib.rs` around lines 500 - 516, Update the cancellation logic
in the function containing vested_amount, recipient_payout, and sender_refund so
the persisted end-time bound uses the effective vesting cutoff represented by
vested, rather than raw ledger timestamp now. Ensure paused-then-canceled
streams retain a schedule consistent with their frozen vested amount, while
preserving the existing start-time lower bound and normal active-stream
behavior.

@ritik4ever

Copy link
Copy Markdown
Owner

Hi @adesuwa-tech,

This PR could not be merged because it has merge conflicts with the target branch.

Please resolve the merge conflicts, push the updated changes, and the PR can be reviewed and merged.

Thank you!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
contracts/src/test.rs (1)

2506-3171: 🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Test bodies and names are mismatched, and some functions use undefined variables. This block will not compile.

This range shows systemic corruption, consistent with the unresolved merge conflicts noted in the PR objectives. Concrete evidence:

  • Line 2513 opens fn test_get_allowed_tokens_returns_initialized_list() with no body before line 2514's comment // #594 — Comprehensive stream state transitions & edge-case coverage appears inside the function. The next function, test_full_lifecycle_create_claim_complete (line 2519), has a body (lines 2520-2536) that actually exercises get_allowed_tokens, not a lifecycle claim flow.
  • test_add_allowed_token_appends_to_list (line 2551) never calls add_allowed_token; its body (lines 2552-2578) creates a stream and claims against it, and it references sender, recipient, admin, env, and client without declaring any of them in this function.
  • test_add_allowed_token_non_admin_panics (line 2622, #[should_panic(expected = "unauthorized")]) has a body (lines 2623-2648) that cancels and claims a stream; nothing in it raises an "unauthorized" panic.
  • Line 2659: j let attacker = Address::generate(&env); has a stray j character before let, which is a syntax error.
  • test_over_claim_after_partial_claim (line 3005, #[should_panic(expected = "amount exceeds claimable")]) has a body (lines 3006-3021) that calls set_admin/add_allowed_token and its own comment states "without panicking," contradicting the #[should_panic] attribute.
  • test_create_stream_with_allowlisted_token_succeeds (line 2792), test_create_stream_with_non_allowlisted_token_panics (line 2832), test_create_stream_rejected_after_token_removed_from_allowlist (line 2871), test_create_stream_succeeds_after_token_added_to_allowlist (line 2914), test_set_admin_transfers_admin_role (line 2964), test_set_admin_old_admin_loses_privileges (line 3027), test_set_admin_non_admin_panics (line 3063), and test_set_admin_chain_transfer (line 3114) all reference env, admin, sender, recipient, or client with no corresponding let env = Env::default();/registration/Address::generate boilerplate anywhere in the function body. These are self-contained scoping errors.

Reconstruct this section from a correct merge resolution rather than patching individual lines; the doc comments, #[should_panic] attributes, and bodies need to be re-paired correctly, and every test needs its own setup boilerplate.

#!/bin/bash
# Description: Spot-check functions flagged as missing setup boilerplate or as mismatched with their names/panics.
sed -n '2506,2537p;2551,2663p;2790,2930p;2960,3141p' contracts/src/test.rs

As per the PR objectives, "The conflicts must be resolved and the updated changes pushed before review and merge," and this block appears to reflect that unresolved conflict.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/test.rs` around lines 2506 - 3171, Reconstruct the corrupted
test section from the intended merge result rather than applying isolated edits:
pair each doc comment and #[should_panic] attribute with its matching test body,
restore the expected behavior for symbols such as
test_get_allowed_tokens_returns_initialized_list,
test_add_allowed_token_appends_to_list, test_over_claim_after_partial_claim, and
the allowlist/admin tests, remove the stray character before attacker, and give
every test its own Env, contract/client, and address setup so no variables are
out of scope and the section compiles.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@contracts/src/test.rs`:
- Around line 2506-3171: Reconstruct the corrupted test section from the
intended merge result rather than applying isolated edits: pair each doc comment
and #[should_panic] attribute with its matching test body, restore the expected
behavior for symbols such as test_get_allowed_tokens_returns_initialized_list,
test_add_allowed_token_appends_to_list, test_over_claim_after_partial_claim, and
the allowlist/admin tests, remove the stray character before attacker, and give
every test its own Env, contract/client, and address setup so no variables are
out of scope and the section compiles.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a4f3698a-7c97-49ee-9b94-1258bfc19f29

📥 Commits

Reviewing files that changed from the base of the PR and between 97b0bab and ac95a6f.

📒 Files selected for processing (2)
  • contracts/src/lib.rs
  • contracts/src/test.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • contracts/src/lib.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Contract claimable function returns incorrect value after stream is canceled

2 participants