Skip to content
Open
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
38 changes: 36 additions & 2 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub struct Stream {
pub canceled: bool,
pub paused: bool,
pub pause_started_at: Option<u64>,
pub completed: bool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Locate manifests and inspect persisted Stream storage plus any migration support.
fd -a -t f 'Cargo.toml' .
rg -n -C3 'struct Stream|DataKey::Stream|persistent\(\).*set|persistent\(\).*get' contracts
rg -n -i -C2 'migration|migrate|upgrade|legacy|versioned' .

Repository: ritik4ever/stellar-stream

Length of output: 50382


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'Cargo.toml manifests:\n'
fd -a -t f 'Cargo.toml' . | sed 's#^\./##'

printf '\ncontracts/src/lib.rs outline and relevant sections:\n'
wc -l contracts/src/lib.rs contracts/src/test.rs 2>/dev/null || true
ast-grep outline contracts/src/lib.rs --view expanded 2>/dev/null | sed -n '1,220p' || true
printf '\nRelevant contracts/src/lib.rs:\n'
sed -n '1,220p' contracts/src/lib.rs

printf '\nFocused storage/key references:\n'
rg -n -C3 'struct Stream|enum DataKey|DataKey::Stream|DataKey::\w|persistent\(\)[^(]+\(\w*::[A-Z][[:alpha:]]*|StorageKey::Persistent|try_from_slice|IntoVal|FromVal|completed' contracts/src/lib.rs contracts/src/test.rs 2>/dev/null || true

printf '\nFocused migration/upgrade references:\n'
rg -n -i -C2 'migration|migrate|upgrade|legacy|versioned|schema|compat|backward|pending|completed' contracts . 2>/dev/null | rg -v 'target|node_modules|dist|build|package-lock' || true

Repository: ritik4ever/stellar-stream

Length of output: 50382


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'Cargo manifests under tracked files:\n'
git ls-files '*Cargo.toml' 

printf '\ncontracts/src/lib.rs file size and relevant sections only:\n'
wc -l contracts/src/lib.rs contracts/src/test.rs
sed -n '1,240p' contracts/src/lib.rs

printf '\nFocused contract storage/key references from tracked contract files:\n'
rg -n -C4 'struct Stream|enum DataKey|DataKey::Stream|DataKey::|StorageKey::Persistent|persistent\(|try_from_slice|IntoVal|FromVal|completed' contracts/src/lib.rs contracts/src/test.rs

printf '\nFocused migration/upgrade references in tracked repo files (except lock/package dirs):\n'
rg -n -i -C2 'migration|migrate|upgrade|legacy|versioned|schema|compat|backward|pending|completed' --glob '!target/**' --glob '!node_modules/**' --glob '!package-lock.json' .

Repository: ritik4ever/stellar-stream

Length of output: 50382


🌐 Web query:

Soroban rust-sdk contracttype contract storage serialization struct order add field breaking upgrade

💡 Result:

In the Soroban Rust SDK, contract data is serialized to XDR using the #[contracttype] macro [1][2]. Because Soroban contracts upgrade by replacing the WASM bytecode while leaving existing contract storage in-place, changing data structures is a high-risk operation [3]. Adding a field to a #[contracttype] struct or changing field ordering will alter the serialized XDR format, making the new WASM binary incompatible with data already stored in the ledger [3]. If your new contract attempts to deserialize existing storage using an updated struct definition, the following issues will likely occur: 1. Deserialization Failure: If the new struct has more fields than the stored data contains, the SDK will typically panic at runtime during deserialization [3]. 2. Silent Data Corruption: If the new struct has fewer fields, or if field types are modified (e.g., changing a type's underlying representation), the SDK may silently misinterpret the stored bytes, leading to data loss or state corruption [3]. Best Practices for Upgrades and Schema Evolution: - Manual Migration: There is no automatic migration callback in Soroban [3]. You must implement a dedicated migrate() entrypoint in your new WASM code to manually transform existing storage entries to the new schema [4][3][5]. - Atomic Upgrades: To avoid state inconsistencies, consider using a dedicated upgrader contract or pausing external access to the contract before and during the migration process [4][5]. - Version Tracking: Include a version identifier in your contract instance storage (e.g., using contractmeta! or a specific AppVersion key) [4][6]. Your logic should check this version to determine if a migration is required before proceeding with normal operations [4][3]. - Append-Only Logic: If you must evolve storage, a common pattern is to avoid modifying existing fields. Instead, create new storage keys for new data or maintain legacy keys alongside new ones until the migration is complete [3]. - Rollback Strategy: Always define a rollback plan. Ensure your upgrade process leaves the contract in a state that can be restored or corrected if the new code fails [4][5]. Because storage entries persist through upgrades, you must ensure that any changes to your data schema are explicitly handled by your contract logic to prevent reading inconsistent or incompatible state [3][5].

Citations:


Preserve compatibility with existing persisted streams.

Stream is a persisted #[contracttype] record; adding completed changes its serialized on-chain format. Existing DataKey::Stream entries written without this field can become undecodable after upgrade, so add an explicit migration/versioned schema path before deploying this contract.

🤖 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` at line 28, Update the persisted Stream contracttype
schema and DataKey::Stream access path to preserve decoding of records written
before completed was added. Implement an explicit migration or versioned schema
that reads the legacy Stream format, supplies the completed value, and writes
the new format before normal access; do not deploy the incompatible field
addition without this compatibility path.


pub metadata: Option<Map<String, String>>,
}
Expand Down Expand Up @@ -78,6 +79,15 @@ pub struct StreamCanceled {
pub sender: Address,
}

#[contracttype]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StreamCompleted {
pub stream_id: u64,
pub recipient: Address,
pub total_amount: i128,
pub completed_at: u64,
}

#[contracttype]
#[derive(Clone, Debug, PartialEq, Eq)]

Expand Down Expand Up @@ -213,6 +223,7 @@ impl StellarStreamContract {
canceled: false,
paused: false,
pause_started_at: None,
completed: false,

metadata: metadata.clone(),
};
Expand Down Expand Up @@ -311,6 +322,7 @@ impl StellarStreamContract {
canceled: false,
paused: false,
pause_started_at: None,
completed: false,
metadata: None,
};

Expand Down Expand Up @@ -361,7 +373,11 @@ impl StellarStreamContract {
}

pub fn get_stream(env: Env, stream_id: u64) -> Stream {
read_stream(&env, stream_id)
let mut stream = read_stream(&env, stream_id);
if !stream.completed && !stream.canceled && env.ledger().timestamp() >= stream.end_time {
stream.completed = true;
}
Comment on lines +376 to +379

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

Honor paused time when deriving completion.

A stream paused before end_time is reported completed once wall-clock time reaches its original end, although vested_amount freezes at pause_started_at and resume_stream extends end_time. Derive completion from the same effective timestamp used for vesting, and apply that rule in claim as well.

🤖 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 376 - 379, Update the completion checks in
the stream-loading logic and the claim flow to compare against the effective
vesting timestamp that accounts for paused duration, rather than the raw ledger
timestamp versus the original end_time. Reuse the existing paused-time/vesting
calculation and ensure completion remains false while paused, then becomes true
only when the effective timestamp reaches the extended end_time.

stream
}

pub fn get_next_stream_id(env: Env) -> u64 {
Expand Down Expand Up @@ -444,15 +460,33 @@ impl StellarStreamContract {
token_client.transfer(&contract_address, &recipient, &amount);

stream.claimed_amount += amount;

let newly_completed = !stream.completed && !stream.canceled && now >= stream.end_time;
if newly_completed {
stream.completed = true;
Comment on lines +464 to +466

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make persisted completion a terminal state.

A partial claim at end_time sets completed = true, yet a later claim still succeeds because claim never checks the flag. cancel also accepts completed streams, replacing completion with cancellation. The current panic test only fails because the balance is already zero.

  • contracts/src/lib.rs#L464-L466: reject stored completed streams in claim; require the terminal claim to withdraw the full remaining claimable balance before persisting completion, and reject cancellation of completed streams.
  • contracts/src/test.rs#L2443-L2466: add a partial terminal-claim case and assert a subsequent claim is rejected due to completion rather than zero claimable balance.
  • contracts/src/test.rs#L2469-L2494: add a completed-then-cancelled case and assert cancellation is rejected.
📍 Affects 2 files
  • contracts/src/lib.rs#L464-L466 (this comment)
  • contracts/src/test.rs#L2443-L2466
  • contracts/src/test.rs#L2469-L2494
🤖 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 464 - 466, Make persisted completion
terminal in the stream claim and cancellation logic in
contracts/src/lib.rs:464-466: reject stored completed streams in claim, ensure
the terminal claim withdraws the full remaining claimable balance before
persisting completed, and reject cancellation of completed streams. In
contracts/src/test.rs:2443-2466, add a partial terminal-claim scenario and
verify a later claim is rejected for completion rather than zero balance; in
contracts/src/test.rs:2469-2494, add a completed-then-cancelled scenario and
verify cancellation is rejected.

}

env.storage()
.persistent()
.set(&DataKey::Stream(stream_id), &stream);

env.events().publish(
(symbol_short!("Stream"), symbol_short!("Claimed")),
StreamClaimed { stream_id, recipient, amount },
StreamClaimed { stream_id, recipient: recipient.clone(), amount },
);

if newly_completed {
env.events().publish(
(symbol_short!("Stream"), symbol_short!("Completed")),
StreamCompleted {
stream_id,
recipient,
total_amount: stream.total_amount,
completed_at: now,
},
);
}

amount
}

Expand Down
160 changes: 160 additions & 0 deletions contracts/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ fn test_vested_amount_fuzz_invariants() {
canceled: false,
paused: false,
pause_started_at: None,
completed: false,
metadata: None,
};

Expand Down Expand Up @@ -1419,6 +1420,7 @@ fn test_resume_stream_panic_on_missing_timestamp() {
canceled: false,
paused: true,
pause_started_at: None,
completed: false,
metadata: None,
};

Expand Down Expand Up @@ -2332,3 +2334,161 @@ fn test_cancel_after_partial_claim_full_lifecycle() {
let recipient_balance = token_client.balance(&recipient);
assert_eq!(sender_refund + recipient_balance, 100);
}

// =============================================================================
// #592 — Contract-level stream expiry and auto-complete logic
// =============================================================================

/// A stream is not marked completed the instant before its end time.
#[test]
fn test_stream_not_completed_before_end_time() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, StellarStreamContract);
let client = StellarStreamContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let token = create_token(&env, &admin);
let token_admin = token::StellarAssetClient::new(&env, &token);
token_admin.mint(&sender, &1000);

let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None);

env.ledger().with_mut(|l| l.timestamp = 999);
let stream = client.get_stream(&stream_id);
assert!(!stream.completed);
}

/// Boundary: get_stream reports completed == true exactly when now == end_time.
#[test]
fn test_get_stream_completed_exactly_at_end_time() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, StellarStreamContract);
let client = StellarStreamContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let token = create_token(&env, &admin);
let token_admin = token::StellarAssetClient::new(&env, &token);
token_admin.mint(&sender, &1000);

let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None);

env.ledger().with_mut(|l| l.timestamp = 1000);
let stream = client.get_stream(&stream_id);
assert!(stream.completed);
}

/// Boundary: get_stream still reports completed == true one second after end_time.
#[test]
fn test_get_stream_completed_one_second_after_end_time() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, StellarStreamContract);
let client = StellarStreamContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let token = create_token(&env, &admin);
let token_admin = token::StellarAssetClient::new(&env, &token);
token_admin.mint(&sender, &1000);

let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None);

env.ledger().with_mut(|l| l.timestamp = 1001);
let stream = client.get_stream(&stream_id);
assert!(stream.completed);
}

/// A claim call made after end_time finalizes the stream: the completed flag is
/// persisted and a StreamCompleted event is emitted.
#[test]
fn test_claim_after_end_time_finalizes_stream_and_emits_event() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, StellarStreamContract);
let client = StellarStreamContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let token = create_token(&env, &admin);
let token_admin = token::StellarAssetClient::new(&env, &token);
token_admin.mint(&sender, &1000);

let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None);

// Exactly at end_time — the full amount is claimable and the claim finalizes the stream.
env.ledger().with_mut(|l| l.timestamp = 1000);
let claimed = client.claim(&stream_id, &recipient, &1000);
assert_eq!(claimed, 1000);

let stream = client.get_stream(&stream_id);
assert!(stream.completed);

let last_event = env.events().all().last().unwrap();
assert_eq!(last_event.0, contract_id);
assert_eq!(
last_event.1,
(symbol_short!("Stream"), symbol_short!("Completed")).into_val(&env)
);
let event_data: StreamCompleted = last_event.2.into_val(&env);
assert_eq!(event_data.stream_id, stream_id);
assert_eq!(event_data.recipient, recipient);
assert_eq!(event_data.total_amount, 1000);
assert_eq!(event_data.completed_at, 1000);
}

/// Once a stream is completed (fully vested and fully claimed), no further claims
/// are accepted — even one second after end_time.
#[test]
#[should_panic(expected = "amount exceeds claimable")]
fn test_no_further_claims_after_completion() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, StellarStreamContract);
let client = StellarStreamContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let token = create_token(&env, &admin);
let token_admin = token::StellarAssetClient::new(&env, &token);
token_admin.mint(&sender, &1000);

let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None);

env.ledger().with_mut(|l| l.timestamp = 1000);
client.claim(&stream_id, &recipient, &1000);

// One second after end_time, everything has already been claimed.
env.ledger().with_mut(|l| l.timestamp = 1001);
client.claim(&stream_id, &recipient, &1);
}

/// A canceled stream is never reported as completed, even long after its
/// (shortened) end_time has passed — cancellation and completion are distinct
/// terminal states.
#[test]
fn test_canceled_stream_is_never_marked_completed() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, StellarStreamContract);
let client = StellarStreamContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let token = create_token(&env, &admin);
let token_admin = token::StellarAssetClient::new(&env, &token);
token_admin.mint(&sender, &1000);

let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None);

env.ledger().with_mut(|l| l.timestamp = 500);
client.cancel(&stream_id, &sender);

env.ledger().with_mut(|l| l.timestamp = 9999);
let stream = client.get_stream(&stream_id);
assert!(stream.canceled);
assert!(!stream.completed);
}