Skip to content

feat(payment-stream): support linear vesting schedule with cliff period in escrow - #609

Merged
Idrhas merged 1 commit into
Fundable-Protocol:mainfrom
BernardOnuh:feat/513-vesting-cliff
Aug 4, 2026
Merged

feat(payment-stream): support linear vesting schedule with cliff period in escrow#609
Idrhas merged 1 commit into
Fundable-Protocol:mainfrom
BernardOnuh:feat/513-vesting-cliff

Conversation

@BernardOnuh

@BernardOnuh BernardOnuh commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #513

Adds linear vesting with an optional cliff (lockup) period to the payment-stream escrow contract so project milestone grants can withhold all funds until a cliff elapses, then release them linearly.

What changed

Contract (contracts/payment-stream/src/lib.rs)

  • New public create_stream_with_cliff(...) (shared internal helper keeps create_stream behavior identical, cliff = 0).
  • Stream gains a cliff_duration field, surfaced by get_stream.
  • withdrawable_amount returns 0 until cliff_duration seconds after start_time; after the cliff, tokens vest linearly over the full [start_time, end_time] window, so the pro-rata share accrued during the cliff is claimable at the boundary (standard cliff semantics).
  • New Error::InvalidCliff (Replace any types with proper TypeScript interfaces #23) when cliff_duration >= end_time - start_time.
  • Fully documented public functions, authorization via require_auth, and TTL management on all touched storage keys.

Also fixed pre-existing broken-merge compile errors on main (from PRs #508/#510) that left the crate unbuildable (28 errors) and the emergency-pause circuit breaker non-functional:

  • Imported missing Symbol/Vec; defined the DexRouter #[contractclient]; added missing InvalidSwapPath/SlippageExceeded/SwapFailed error variants.
  • Unified emergency-pause / dex-router storage under the DataKey enum (they were reading raw symbol keys never written by initialize, causing NotInitialized).
  • Removed calls to the non-existent require_not_paused (redundant with assert_not_paused).
  • Fixed events.events().len() assertions in the circuit-breaker tests.

Tests — 10 new cliff tests (creation, invalid cliff, pre-cliff block, boundary release, linear midpoints, withdraw/withdraw_max before cliff, cancel refund, pause/resume interaction).

Verification

  • cargo test -p payment-stream: 65 passed / 0 failed
  • cargo build -p payment-stream --release: 0 warnings
  • cargo build -p payment-stream --target wasm32v1-none --release: builds cleanly.

Note: the acceptance-criteria command cargo build --target wasm32-unknown-unknown cannot run on Rust ≥ 1.82 with soroban-sdk 25.3.2 — its build.rs hard-rejects that target (requires wasm32v1-none or Rust ≤ 1.81). I verified the canonical Soroban wasm32v1-none target instead.

Summary by CodeRabbit

  • New Features

    • Added cliff-enabled payment streams with configurable lockup periods.
    • Added cliff-aware vesting, withdrawals, refunds, and validation.
    • Added DEX token swap support with slippage and swap error handling.
    • Improved pause and resume behavior across stream operations.
  • Documentation

    • Updated payment-stream documentation with cliff configuration and vesting details.
  • Tests

    • Added comprehensive coverage for cliff vesting, withdrawals, cancellations, and pause interactions.

Add an optional cliff (lockup) period to the payment-stream escrow contract
so milestone grants can release nothing until a cliff elapses, then vest
linearly.

- Add cliff_duration field to Stream and create_stream_with_cliff(...).
- enforce the cliff in withdrawable_amount: 0 until the same elapses, then
  linear vesting over the full window (pro-rata cliff share available at
  boundary). Reject cliffs that are not shorter than the total duration.
- Add InvalidCliff error code and update create_stream docs.

Also fix pre-existing broken-merge compile errors on main (from Fundable-Protocol#508/Fundable-Protocol#510):
- import Symbol/Vec, define DexRouter contract client, add missing
  InvalidSwapPath/SlippageExceeded/SwapFailed error variants.
- unify emergency-pause and dex-router storage under the DataKey enum so the
  circuit breaker works (was reading unset symbol keys -> NotInitialized).
- remove references to the non-existent require_not_paused.
- fix events.events() snapshots len assertions in tests.

Adds 10 cliff vesting tests (success, failure, edge cases). cargo test: 65/65.
Closes Fundable-Protocol#513
@drips-wave

drips-wave Bot commented Aug 3, 2026

Copy link
Copy Markdown

@BernardOnuh 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 Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The payment-stream contract now supports optional cliff periods for linear vesting. It adds typed pause and DEX-router storage keys, swap-related declarations, cliff validation, cliff-aware withdrawals, comprehensive tests, and updated documentation.

Changes

Payment stream contract

Layer / File(s) Summary
Typed storage and router integration
contracts/payment-stream/src/lib.rs, contracts/payment-stream/src/test.rs
The contract adds typed pause and DEX-router keys, swap errors, and a public DexRouter interface. Pause, router, stream, and metrics operations use typed storage keys.
Cliff stream creation and withdrawal
contracts/payment-stream/src/lib.rs, contracts/README.md, docs/contracts/payment-stream.md
Stream stores cliff_duration. create_stream_with_cliff validates and persists the cliff. Withdrawals return zero before the cliff and use linear vesting after it.
Cliff behavior validation
contracts/payment-stream/src/test.rs
Tests cover cliff validation, zero-cliff compatibility, withdrawal boundaries, linear accrual, cancellation refunds, withdraw_max, and pause/resume behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: damiedee96

Sequence Diagram(s)

sequenceDiagram
  participant Sender
  participant PaymentStream
  participant StreamStorage
  Sender->>PaymentStream: create_stream_with_cliff(...)
  PaymentStream->>StreamStorage: store stream and cliff_duration
  PaymentStream->>PaymentStream: calculate withdrawable amount
  PaymentStream-->>Sender: release vested amount after cliff
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also changes DexRouter and pause storage, adds swap errors, and adds a router client that issue #513 does not require. Move unrelated DexRouter and pause changes to a separate PR unless maintainers explicitly expand issue #513 scope.
Linked Issues check ❓ Inconclusive The core vesting, authorization, error, test, storage, and documentation objectives are addressed, but the required wasm32-unknown-unknown build was not verified. Verify a clean cargo build with target wasm32-unknown-unknown, or document an accepted replacement target and update the issue acceptance criterion.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding linear vesting with an optional cliff period to the payment-stream escrow contract.
✨ Finishing Touches
🧪 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
Contributor

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 (2)
contracts/payment-stream/src/lib.rs (2)

610-644: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

deposit_with_swap does not enforce the emergency pause circuit breaker.

create_stream_internal, deposit, withdraw, and withdraw_max all call Self::assert_not_paused(&env) as their first check. deposit_with_swap does not call it anywhere in its body (lines 610-750). While the global pause flag is active, callers can still route funds into a stream through deposit_with_swap, defeating the purpose of emergency_pause.

The doc comment for emergency_pause (lines 268-272) states that pausing blocks create_stream, deposit, withdraw, and withdraw_max, but does not mention deposit_with_swap, and the deposit_with_swap error table (lines 601-609) never lists ContractPaused. This looks like an oversight rather than an intentional exclusion, since deposit_with_swap is functionally a deposit path.

Add the guard at the top of the function, and add a corresponding test (mirroring test_deposit_blocked_when_paused) to confirm the behavior.

🛡️ Proposed fix
     pub fn deposit_with_swap(
         env: Env,
         stream_id: u64,
         from_token: Address,
         amount_in: i128,
         min_amount_out: i128,
         swap_path: Vec<Address>,
     ) {
+        Self::assert_not_paused(&env);
+
         // 1. Load stream and validate status
         let mut stream: Stream = Self::get_stream(env.clone(), stream_id);
🤖 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/payment-stream/src/lib.rs` around lines 610 - 644, Add
Self::assert_not_paused(&env) as the first operation in deposit_with_swap,
before loading the stream or validating inputs, so the emergency pause blocks
this deposit path consistently. Update the emergency_pause documentation and
deposit_with_swap error table to include ContractPaused, and add a test
mirroring test_deposit_blocked_when_paused that verifies paused calls are
rejected.

44-60: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add migration protection for Stream storage upgrades.

Stream is persisted with #[contracttype], so existing DataKey::Stream(stream_id) values can miss the new cliff_duration map entry. If this contract may upgrade in place over an installed instance, add a one-time storage migration or a versioned lazy migration for legacy stream records before reading with get_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/payment-stream/src/lib.rs` around lines 44 - 60, Add migration
protection for persisted Stream records before get_stream reads them, using a
one-time or versioned lazy migration that detects legacy
DataKey::Stream(stream_id) values missing cliff_duration, initializes the field
with the intended legacy default, and records migration completion so it is not
repeated.
🧹 Nitpick comments (2)
contracts/payment-stream/src/lib.rs (2)

414-448: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a params struct for create_stream_with_cliff / create_stream_internal.

Clippy flags both create_stream_with_cliff (line 414) and create_stream_internal (line 439) for having 9 arguments, exceeding its default threshold of 7. Group the creation parameters into a single struct to simplify the signature and reduce the risk of argument-order mistakes at call sites.

🤖 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/payment-stream/src/lib.rs` around lines 414 - 448, Introduce a
dedicated stream-creation parameters struct and use it for both
create_stream_with_cliff and create_stream_internal, grouping sender, recipient,
token, amounts, times, and cliff_duration into one value. Update all call sites
and field accesses while preserving the existing creation behavior and argument
values.

Source: Linters/SAST tools


2-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Narrow the scope of #![allow(deprecated)].

This attribute suppresses deprecated-item warnings for the entire crate. A file-wide allowance can hide future deprecation warnings unrelated to the intended exception. Apply #[allow(deprecated)] on the specific item or import that needs it instead.

🤖 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/payment-stream/src/lib.rs` around lines 2 - 6, Restrict the
deprecated-warning suppression currently applied by the crate-level
#![allow(deprecated)] to only the specific import or item that requires it.
Remove the file-wide attribute and annotate the relevant soroban_sdk usage with
#[allow(deprecated)], leaving unrelated code subject to deprecation warnings.
🤖 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/payment-stream/src/lib.rs`:
- Around line 610-644: Add Self::assert_not_paused(&env) as the first operation
in deposit_with_swap, before loading the stream or validating inputs, so the
emergency pause blocks this deposit path consistently. Update the
emergency_pause documentation and deposit_with_swap error table to include
ContractPaused, and add a test mirroring test_deposit_blocked_when_paused that
verifies paused calls are rejected.
- Around line 44-60: Add migration protection for persisted Stream records
before get_stream reads them, using a one-time or versioned lazy migration that
detects legacy DataKey::Stream(stream_id) values missing cliff_duration,
initializes the field with the intended legacy default, and records migration
completion so it is not repeated.

---

Nitpick comments:
In `@contracts/payment-stream/src/lib.rs`:
- Around line 414-448: Introduce a dedicated stream-creation parameters struct
and use it for both create_stream_with_cliff and create_stream_internal,
grouping sender, recipient, token, amounts, times, and cliff_duration into one
value. Update all call sites and field accesses while preserving the existing
creation behavior and argument values.
- Around line 2-6: Restrict the deprecated-warning suppression currently applied
by the crate-level #![allow(deprecated)] to only the specific import or item
that requires it. Remove the file-wide attribute and annotate the relevant
soroban_sdk usage with #[allow(deprecated)], leaving unrelated code subject to
deprecation warnings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fb57af0-286e-4392-9408-c7de5dc784f8

📥 Commits

Reviewing files that changed from the base of the PR and between 9bfc775 and 520caa4.

📒 Files selected for processing (4)
  • contracts/README.md
  • contracts/payment-stream/src/lib.rs
  • contracts/payment-stream/src/test.rs
  • docs/contracts/payment-stream.md

@Idrhas
Idrhas merged commit 520caa4 into Fundable-Protocol:main Aug 4, 2026
1 check passed
@coderabbitai coderabbitai Bot mentioned this pull request Aug 10, 2026
10 tasks
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.

[Contract] Support Linear Vesting Schedule with Cliff Period in Escrow

2 participants