diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index 5592851b..fd62ed1f 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -32,6 +32,7 @@ pub mod errors; pub mod events; pub mod storage; pub mod types; +pub mod yield_strategy; // #[cfg(test)] // mod acceptance_tests; @@ -434,6 +435,7 @@ impl StreamContract { schedule: VestingSchedule::Linear, arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }, ); @@ -568,6 +570,7 @@ impl StreamContract { schedule: VestingSchedule::Linear, arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }, ); @@ -661,6 +664,7 @@ impl StreamContract { schedule: VestingSchedule::Linear, arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }, ); @@ -859,6 +863,7 @@ impl StreamContract { schedule: VestingSchedule::StepTranches(steps), arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }, ); @@ -957,6 +962,7 @@ impl StreamContract { schedule: VestingSchedule::HybridCliffLinear(cliff_time, cliff_unlock_amount), arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }, ); @@ -1142,6 +1148,7 @@ impl StreamContract { schedule: VestingSchedule::StepTranches(steps), arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }, ); @@ -2365,6 +2372,7 @@ impl StreamContract { schedule: VestingSchedule::Linear, arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: true, }, ); diff --git a/contracts/stream_contract/src/storage.rs b/contracts/stream_contract/src/storage.rs index 1cab8445..729c0c0f 100644 --- a/contracts/stream_contract/src/storage.rs +++ b/contracts/stream_contract/src/storage.rs @@ -104,7 +104,7 @@ use crate::types::{ /// here only so the two can be told apart before a decode is attempted. const CONFIG_FIELD_COUNT: u32 = 5; const LEGACY_CONFIG_FIELD_COUNT: u32 = 3; -const STREAM_FIELD_COUNT: u32 = 17; +const STREAM_FIELD_COUNT: u32 = 18; const LEGACY_STREAM_FIELD_COUNT: u32 = 12; /// Returns the number of fields in a stored record, or `None` if it is not a map. @@ -288,6 +288,7 @@ fn upgrade_legacy_stream(legacy: LegacyStream) -> Stream { // New fields default to no arbiter, no dispute, and non-allowance-based. arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, } } diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index 26d7daa4..3585d7f5 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -178,6 +178,7 @@ fn test_datakey_stream_serializes_deterministically() { schedule: VestingSchedule::Linear, arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, }; env.as_contract(&contract_id, || { @@ -2487,6 +2488,7 @@ fn test_fuzz_claimable_overflow_and_cancel_invariants() { schedule: VestingSchedule::Linear, arbiter: None, dispute_status: DisputeStatus::None, + yield_enabled: false, is_allowance_based: false, status: if paused { StreamStatus::Paused @@ -4791,9 +4793,9 @@ fn raw_stream_field_count(env: &Env, contract: &Address, stream_id: u64) -> u32 /// True when the raw record at `stream_id` decodes as the current [`Stream`]. fn stream_record_is_current_shape(env: &Env, contract: &Address, stream_id: u64) -> bool { - // The current `Stream` shape is 17 fields: `LegacyStream` carries neither - // `schedule`/`cliff_time` nor the dispute/allowance fields. - raw_stream_field_count(env, contract, stream_id) == 17 + // The current `Stream` shape is 18 fields: `LegacyStream` carries neither + // `schedule`/`cliff_time` nor the dispute/allowance/yield fields. + raw_stream_field_count(env, contract, stream_id) == 18 } /// True when the raw record at `stream_id` decodes as the pre-v2 [`LegacyStream`]. @@ -6703,3 +6705,61 @@ fn test_bump_position_ttl_helper_directly() { Err(Ok(StreamError::StreamNotFound)) ); } + +// ─── Yield Strategy Tests ────────────────────────────────────────────────── + +#[test] +fn test_yield_enabled_stored_on_stream_creation() { + 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, 1_000); + + let client = create_contract(&env); + // Create stream with yield_enabled concept - the flag is stored in state. + // Since the public API doesn't yet accept a yield_enabled parameter, + // we verify the field exists and defaults to false by checking + // the stream struct directly. + let id = client.create_stream(&sender, &recipient, &token, &1_000, &100); + let stream = client.get_stream(&id).unwrap(); + assert!( + !stream.yield_enabled, + "yield_enabled should default to false" + ); +} + +#[test] +fn test_yield_distribution_mock() { + // Yield generation is opt-in and disabled by default, so a stream still + // follows the standard fee/withdraw math. This mock exercises that path + // without requiring a real yield-pool contract. + use soroban_sdk::Env; + + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let client = create_contract(&env); + client.initialize(&treasury, &treasury, &250); + + let id = client.create_stream(&sender, &recipient, &token, &1_000, &100); + + // Advance time so some tokens accrue. + env.ledger().with_mut(|l| l.timestamp += 100); + + // Withdraw should work and consume the accrued amount. The 250 bps fee + // leaves 975 deposited, so the rate is 9 tokens/second over 100 seconds. + let claimed = client.withdraw(&recipient, &id); + assert_eq!(claimed, 900); + + // Verify the stream has recorded the withdrawal against the net deposit. + let stream = client.get_stream(&id).unwrap(); + assert_eq!(stream.withdrawn_amount, 900); + assert_eq!(stream.deposited_amount, 975); +} diff --git a/contracts/stream_contract/src/types.rs b/contracts/stream_contract/src/types.rs index a83a4778..27c65409 100644 --- a/contracts/stream_contract/src/types.rs +++ b/contracts/stream_contract/src/types.rs @@ -244,6 +244,9 @@ pub struct Stream { pub arbiter: Option
, /// Dispute status for escrow cancellations (for #1319). pub dispute_status: DisputeStatus, + /// Whether this stream has yield generation enabled. + /// When true, idle deposited tokens are routed to a yield strategy adapter. + pub yield_enabled: bool, /// Whether this stream uses allowance-based funding (for #1318). pub is_allowance_based: bool, } diff --git a/contracts/stream_contract/src/yield_strategy.rs b/contracts/stream_contract/src/yield_strategy.rs new file mode 100644 index 00000000..b51b5805 --- /dev/null +++ b/contracts/stream_contract/src/yield_strategy.rs @@ -0,0 +1,45 @@ +use crate::errors::StreamError; +use soroban_sdk::{Address, Env}; + +/// Abstract yield adapter interface for Soroban-native DeFi lending markets. +/// +/// Implementations route idle deposited tokens to yield-bearing protocols +/// (e.g. Blend, liquidity pools) in exchange for interest-bearing pool tokens. +/// Upon withdrawal or cancellation, principal is instantly redeemed and excess +/// yield is split according to protocol parameters. +pub trait YieldStrategy { + /// Deposit `amount` of `token` into the yield strategy, receiving shares + /// of the interest-bearing pool in return. + /// + /// Returns the amount of principal actually deposited (may differ from + /// `amount` if the strategy applies fees or min‑share guarantees). + fn deposit(env: &Env, token: &Address, amount: i128) -> Result