diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index 7221c3db..f29f1cc7 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -9,6 +9,9 @@ //! - **Escrow**: time-locked token custody with claim / cancel flows. //! - **Streaming Payments**: continuous per-ledger token streams that //! recipients can drain at any time; payers can top-up or close early. +//! - **Recurring Payments**: streaming payments that automatically chain +//! successive billing cycles, deducting each cycle's deposit from the payer +//! on demand — no manual top-up or close-and-reopen required. //! - **Multi-Sig Payments**: N-of-M threshold approvals before a payment //! executes, fully on-chain with no trusted third-party. //! - **Batch Sends**: fan-out a single token transfer to many recipients in @@ -41,8 +44,8 @@ pub mod streams; pub mod yield_escrow; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, - Symbol, TryIntoVal, Val, Vec, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, + TryIntoVal, Val, Vec, }; use crate::storage::{MIN_TTL_LEDGERS, TTL_CLASS_COUNT}; @@ -275,6 +278,39 @@ pub fn claimable_at(stream: &Stream, current_ledger: u32) -> i128 { (capped - stream.claimed).max(0) } +/// Configuration for a recurring payment stream that automatically chains +/// successive billing cycles. +/// +/// A recurring stream reuses the ordinary `Stream` mechanics: each cycle's +/// deposit drips to the recipient at `rate_per_ledger`. When a cycle is fully +/// claimed, `advance_recurring_stream` marks it complete and — if cycles +/// remain — deducts the next `amount_per_cycle` deposit from the payer, so no +/// manual top-up or close-and-reopen is required. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RecurringStreamConfig { + /// Number of ledgers in each billing cycle. Informational for off-chain + /// schedulers; the actual drip is governed by the stream's rate. + pub cycle_ledgers: u32, + /// Token base units deposited from the payer at the start of each cycle. + pub amount_per_cycle: i128, + /// Total number of cycles. Once `cycles_completed` reaches this value no + /// further advances are possible. + pub max_cycles: u32, + /// How many cycles have been fully claimed and therefore completed. + pub cycles_completed: u32, +} + +/// Read-only view of a recurring stream's lifecycle progress. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RecurringStreamStatus { + /// Cycles fully claimed so far. + pub cycles_completed: u32, + /// Cycles still to be claimed (including the current in-progress cycle). + pub cycles_remaining: u32, +} + #[contracttype] #[derive(Clone, Debug)] pub struct VestingSchedule { @@ -425,7 +461,7 @@ const MAX_ADMIN_SIGNERS: u32 = 20; /// or any persistent struct field layout changes. The admin must call /// `validate_storage_compatibility` before upgrading to ensure the new WASM /// declares a layout version >= this value, preventing bricked storage. -const STORAGE_LAYOUT_VERSION: u32 = 3; +const STORAGE_LAYOUT_VERSION: u32 = 4; // ─── Storage TTL classes ────────────────────────────────────────────────────── @@ -512,6 +548,8 @@ pub enum DataKey { // Streaming StreamCount, Stream(u32), + /// Recurring-payment configuration keyed by the underlying stream id. + RecurringStreamConfig(u32), LockedBalance(Address), LastContractBalance(Address), // Multi-sig @@ -1026,7 +1064,11 @@ impl FinchippayContract { // (fast hot-key path). Admin-initiated pausing goes through // `propose_admin_action`, so no single key can freeze the contract. let stored_pauser: Option
= env.storage().persistent().get(&DataKey::Pauser); - if stored_pauser.as_ref().map(|p| p == &caller).unwrap_or(false) { + if stored_pauser + .as_ref() + .map(|p| p == &caller) + .unwrap_or(false) + { env.storage().persistent().set(&DataKey::Paused, &true); bump_to_floor(&env, &DataKey::Paused); env.events().publish((Symbol::new(&env, "paused"),), ()); @@ -1045,7 +1087,11 @@ impl FinchippayContract { // Mirror `pause`: only the designated pauser lifts the circuit breaker // directly; admin-initiated unpausing uses `propose_admin_action`. let stored_pauser: Option
= env.storage().persistent().get(&DataKey::Pauser); - if stored_pauser.as_ref().map(|p| p == &caller).unwrap_or(false) { + if stored_pauser + .as_ref() + .map(|p| p == &caller) + .unwrap_or(false) + { env.storage().persistent().set(&DataKey::Paused, &false); bump_to_floor(&env, &DataKey::Paused); env.events().publish((Symbol::new(&env, "unpaused"),), ()); @@ -1254,7 +1300,8 @@ impl FinchippayContract { .expect("invalid set_pauser payload"); env.storage().persistent().set(&DataKey::Pauser, &pauser); bump_to_floor(env, &DataKey::Pauser); - env.events().publish((Symbol::new(env, "pauser_set"),), pauser); + env.events() + .publish((Symbol::new(env, "pauser_set"),), pauser); } else if action == &Symbol::new(env, "upgrade") { let wasm_hash: BytesN<32> = proposal .action_data @@ -1271,7 +1318,8 @@ impl FinchippayContract { // Reject downgrades before touching the WASM (same guard as the // legacy single-admin `upgrade` entrypoint). Self::validate_storage_compatibility(env.clone(), layout_version); - env.deployer().update_current_contract_wasm(wasm_hash.clone()); + env.deployer() + .update_current_contract_wasm(wasm_hash.clone()); let current_ver: u32 = env .storage() .persistent() @@ -2392,6 +2440,46 @@ impl FinchippayContract { streams::list_escrows_by_recipient(env, recipient, offset, limit) } + /// Open a recurring payment stream that automatically chains successive + /// billing cycles. The first cycle's `deposit_per_cycle` is transferred + /// from `payer` immediately; each subsequent cycle is funded on demand by + /// `advance_recurring_stream`. + /// + /// Returns the stream ID. + pub fn open_recurring_stream( + env: Env, + token_address: Address, + payer: Address, + recipient: Address, + rate_per_ledger: i128, + deposit_per_cycle: i128, + cycle_ledgers: u32, + max_cycles: u32, + ) -> u32 { + streams::open_recurring_stream( + env, + token_address, + payer, + recipient, + rate_per_ledger, + deposit_per_cycle, + cycle_ledgers, + max_cycles, + ) + } + + /// Advance a recurring stream into its next billing cycle. Anyone may call + /// this; the current cycle must be fully claimed first, and funding the + /// next cycle deducts `amount_per_cycle` from the payer. + pub fn advance_recurring_stream(env: Env, stream_id: u32) { + streams::advance_recurring_stream(env, stream_id) + } + + /// Return the lifecycle progress of a recurring stream. + pub fn get_recurring_status(env: Env, stream_id: u32) -> RecurringStreamStatus { + streams::get_recurring_status(env, stream_id) + } + // Internal: compute claimable amount for a stream at the current ledger. pub(crate) fn _claimable(env: &Env, stream: &Stream) -> i128 { if stream.closed { @@ -3451,15 +3539,9 @@ mod tests { let mut signers = Vec::new(&env); signers.push_back(admin.clone()); signers.push_back(signer_b); - let data: Vec = Vec::from_array( - &env, - [signers.into_val(&env), 2u32.into_val(&env)], - ); - let pid = client.propose_admin_action( - &admin, - &Symbol::new(&env, "set_admin_signers"), - &data, - ); + let data: Vec = Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); + let pid = + client.propose_admin_action(&admin, &Symbol::new(&env, "set_admin_signers"), &data); // Threshold-1 deploy auto-executes the rotation on propose. assert!(client.get_admin_action_proposal(&pid).executed); @@ -3482,11 +3564,8 @@ mod tests { proposed.push_back(signer_a.clone()); proposed.push_back(new_signer); let data: Vec = Vec::from_array(&env, [proposed.into_val(&env), 2u32.into_val(&env)]); - let pid = client.propose_admin_action( - &signer_a, - &Symbol::new(&env, "set_admin_signers"), - &data, - ); + let pid = + client.propose_admin_action(&signer_a, &Symbol::new(&env, "set_admin_signers"), &data); assert!(!client.get_admin_action_proposal(&pid).executed); assert_eq!(client.get_admin_signers().len(), 2); assert_eq!(client.get_admin_signers_threshold(), 2); diff --git a/contracts/finchippay-contract/src/storage.rs b/contracts/finchippay-contract/src/storage.rs index 1537c80f..189744fd 100644 --- a/contracts/finchippay-contract/src/storage.rs +++ b/contracts/finchippay-contract/src/storage.rs @@ -223,6 +223,7 @@ pub fn bump_ttl_class_item(env: &Env, class: &TtlClass, index: u32) -> u32 { Some(stream) => { bump_to_floor(env, &stream_key); 1 + bump_to_floor_if_present(env, &DataKey::StreamByPayer(stream.payer)) + + bump_to_floor_if_present(env, &DataKey::RecurringStreamConfig(index - 1)) } None => 0, } diff --git a/contracts/finchippay-contract/src/streams.rs b/contracts/finchippay-contract/src/streams.rs index e24af2e9..6576f8ce 100644 --- a/contracts/finchippay-contract/src/streams.rs +++ b/contracts/finchippay-contract/src/streams.rs @@ -8,8 +8,8 @@ use soroban_sdk::{Address, Env, Symbol, Vec}; use crate::{ claimable_at, contract_transfer_out, decrease_locked_balance, get_token_client, increase_locked_balance, require_initialized, require_not_paused, require_transfer_succeeded, - ContractError, DataKey, Escrow, Stream, MAX_PAGE_SIZE, MAX_STREAM_DEPOSIT, MAX_STREAM_RATE, - MAX_USER_STREAMS, + ContractError, DataKey, Escrow, RecurringStreamConfig, RecurringStreamStatus, Stream, + MAX_PAGE_SIZE, MAX_STREAM_DEPOSIT, MAX_STREAM_RATE, MAX_USER_STREAMS, }; use crate::storage::*; @@ -99,6 +99,233 @@ pub fn open_stream( id } +// ─── Recurring streams ─────────────────────────────────────────────────────── + +/// Open a recurring payment stream that automatically chains successive +/// billing cycles. +/// +/// The first cycle's `deposit_per_cycle` is transferred from `payer` to the +/// contract immediately. When that cycle is fully claimed, anyone can call +/// `advance_recurring_stream` to mark it complete and deduct the next cycle's +/// deposit from `payer`, up to `max_cycles` total cycles. +/// +/// Returns the stream ID (also used to query `get_recurring_status`). +pub fn open_recurring_stream( + env: Env, + token_address: Address, + payer: Address, + recipient: Address, + rate_per_ledger: i128, + deposit_per_cycle: i128, + cycle_ledgers: u32, + max_cycles: u32, +) -> u32 { + require_initialized(&env); + require_not_paused(&env); + payer.require_auth(); + if payer == recipient { + panic!("cannot open stream to yourself"); + } + if rate_per_ledger <= 0 { + panic!("rate_per_ledger must be positive"); + } + if rate_per_ledger > MAX_STREAM_RATE { + panic!("rate_per_ledger exceeds maximum"); + } + if deposit_per_cycle <= 0 { + panic!("deposit_per_cycle must be positive"); + } + if deposit_per_cycle > MAX_STREAM_DEPOSIT { + panic!("deposit_per_cycle exceeds maximum stream size"); + } + if cycle_ledgers == 0 { + panic!("cycle_ledgers must be positive"); + } + if max_cycles == 0 { + panic!("max_cycles must be positive"); + } + + // Lock the first cycle's deposit in the contract. + let token = get_token_client(&env, &token_address); + let contract_address = env.current_contract_address(); + require_transfer_succeeded(&env, &token, &payer, &contract_address, &deposit_per_cycle); + + let id: u32 = env + .storage() + .persistent() + .get(&DataKey::StreamCount) + .unwrap_or(0); + + let stream = Stream { + id, + payer: payer.clone(), + recipient: recipient.clone(), + token: token_address, + rate_per_ledger, + deposited: deposit_per_cycle, + claimed: 0, + start_ledger: env.ledger().sequence(), + closed: false, + paused_at_ledger: 0, + total_paused_duration: 0, + }; + increase_locked_balance(&env, &stream.token, deposit_per_cycle); + + let config = RecurringStreamConfig { + cycle_ledgers, + amount_per_cycle: deposit_per_cycle, + max_cycles, + cycles_completed: 0, + }; + + env.storage() + .persistent() + .set(&DataKey::Stream(id), &stream); + bump_to_floor(&env, &DataKey::Stream(id)); + env.storage() + .persistent() + .set(&DataKey::RecurringStreamConfig(id), &config); + bump_to_floor(&env, &DataKey::RecurringStreamConfig(id)); + env.storage() + .persistent() + .set(&DataKey::StreamCount, &(id + 1)); + bump(&env, &DataKey::StreamCount); + + let s_key = DataKey::StreamByPayer(payer.clone()); + let mut p_streams: Vec = env + .storage() + .persistent() + .get(&s_key) + .unwrap_or(Vec::new(&env)); + if p_streams.len() < MAX_USER_STREAMS { + p_streams.push_back(id); + env.storage().persistent().set(&s_key, &p_streams); + bump_to_floor(&env, &s_key); + } + + env.events().publish( + (Symbol::new(&env, "stream_open"), id), + ( + payer.clone(), + recipient.clone(), + rate_per_ledger, + deposit_per_cycle, + ), + ); + env.events().publish( + (Symbol::new(&env, "recurring_cycle_start"), id), + (1u32, deposit_per_cycle), + ); + id +} + +/// Advance a recurring stream to its next billing cycle. +/// +/// Anyone may call this, but the cycle only advances once the current cycle's +/// deposit has been fully claimed. Marking a depleted cycle complete is +/// permissionless; funding the next cycle additionally requires the payer's +/// authorisation, because it deducts `amount_per_cycle` from the payer. +/// +/// Once `max_cycles` have been completed, further calls panic. +pub fn advance_recurring_stream(env: Env, stream_id: u32) { + require_not_paused(&env); + + let mut stream: Stream = env + .storage() + .persistent() + .get(&DataKey::Stream(stream_id)) + .expect("stream not found"); + let mut config: RecurringStreamConfig = env + .storage() + .persistent() + .get(&DataKey::RecurringStreamConfig(stream_id)) + .expect("not a recurring stream"); + + if stream.closed { + panic!("stream is closed"); + } + if config.cycles_completed >= config.max_cycles { + panic!("max_cycles reached; no more advances"); + } + // A cycle is depleted only when every deposited unit has been claimed. + if stream.deposited > stream.claimed { + panic!("current cycle not depleted"); + } + + // Mark the depleted cycle complete. + let cycle_just_completed = config.cycles_completed.checked_add(1).expect("overflow"); + config.cycles_completed = cycle_just_completed; + + env.events().publish( + (Symbol::new(&env, "recurring_cycle_end"), stream_id), + cycle_just_completed, + ); + + // Fund the next cycle if one remains. + if cycle_just_completed < config.max_cycles { + // The next deposit is deducted from the payer, so the payer must + // authorise this transfer (even though any address may invoke it). + stream.payer.require_auth(); + let token = get_token_client(&env, &stream.token); + let contract_address = env.current_contract_address(); + require_transfer_succeeded( + &env, + &token, + &stream.payer, + &contract_address, + &config.amount_per_cycle, + ); + increase_locked_balance(&env, &stream.token, config.amount_per_cycle); + stream.deposited = stream + .deposited + .checked_add(config.amount_per_cycle) + .expect("overflow"); + if stream.deposited > MAX_STREAM_DEPOSIT { + panic!("deposit exceeds maximum stream size"); + } + + let next_cycle = cycle_just_completed.checked_add(1).expect("overflow"); + env.events().publish( + (Symbol::new(&env, "recurring_cycle_start"), stream_id), + (next_cycle, config.amount_per_cycle), + ); + } + + env.storage() + .persistent() + .set(&DataKey::Stream(stream_id), &stream); + bump(&env, &DataKey::Stream(stream_id)); + env.storage() + .persistent() + .set(&DataKey::RecurringStreamConfig(stream_id), &config); + bump(&env, &DataKey::RecurringStreamConfig(stream_id)); +} + +/// Return the lifecycle progress of a recurring stream. +/// +/// Panics if `stream_id` does not exist or is not a recurring stream. +pub fn get_recurring_status(env: Env, stream_id: u32) -> RecurringStreamStatus { + if !env.storage().persistent().has(&DataKey::Stream(stream_id)) { + panic!("stream not found"); + } + let config: RecurringStreamConfig = env + .storage() + .persistent() + .get(&DataKey::RecurringStreamConfig(stream_id)) + .expect("not a recurring stream"); + bump(&env, &DataKey::Stream(stream_id)); + bump(&env, &DataKey::RecurringStreamConfig(stream_id)); + + let cycles_remaining = config + .max_cycles + .checked_sub(config.cycles_completed) + .expect("underflow"); + RecurringStreamStatus { + cycles_completed: config.cycles_completed, + cycles_remaining, + } +} + /// Recipient claims all currently claimable tokens from stream `id`. /// /// Returns the amount claimed. Can be called multiple times as the stream diff --git a/contracts/finchippay-contract/tests/integration.rs b/contracts/finchippay-contract/tests/integration.rs index 4981a770..6e190f8a 100644 --- a/contracts/finchippay-contract/tests/integration.rs +++ b/contracts/finchippay-contract/tests/integration.rs @@ -63,7 +63,10 @@ fn test_initialize_cannot_be_called_twice() { let signers = Vec::from_array(&env, [admin.clone()]); client.initialize(&signers, &1); let result = client.try_initialize(&signers, &1); - assert_eq!(result.unwrap_err().unwrap(), ContractError::AlreadyInitialized); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::AlreadyInitialized + ); } #[test] @@ -360,7 +363,14 @@ fn test_claim_escrow_after_release() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 1; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); advance_ledger(&env, release + 1); client.claim_escrow(&id); @@ -381,7 +391,14 @@ fn test_claim_escrow_emits_event() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 1; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); advance_ledger(&env, release + 1); client.claim_escrow(&id); @@ -400,7 +417,14 @@ fn test_cancel_escrow_before_release() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 100; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); client.cancel_escrow(&id); let escrow = client.get_escrow(&id); @@ -418,7 +442,14 @@ fn test_cancel_escrow_emits_event() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 100; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); client.cancel_escrow(&id); let events = env.events().all().filter_by_contract(&contract_id); @@ -436,7 +467,14 @@ fn test_create_escrow_emits_event() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 100; - client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); let events = env.events().all().filter_by_contract(&contract_id); assert_eq!(events.events().len(), 1); @@ -453,7 +491,14 @@ fn test_get_user_escrows() { let token_id = create_token(&env, &admin, &from, 10_000); let release = env.ledger().sequence() + 100; - let id = client.create_escrow(&token_id, &from, &to, &1_000, &release, &Symbol::new(&env, "")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &1_000, + &release, + &Symbol::new(&env, ""), + ); let ids = client.get_user_escrows(&to); assert_eq!(ids.len(), 1); assert_eq!(ids.get(0).unwrap(), id); @@ -470,7 +515,14 @@ fn test_claim_escrow_partial() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 1; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, ""), + ); advance_ledger(&env, release + 1); let remaining = client.claim_escrow_partial(&id, &500); @@ -653,6 +705,169 @@ fn test_list_streams_by_payer() { assert_eq!(streams.len(), 2); } +// ─── Recurring Stream Flow ─────────────────────────────────────────────────── + +#[test] +fn test_open_recurring_stream() { + let env = Env::default(); + let (_, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 5_000); + let sid = client.open_recurring_stream(&token_id, &payer, &recipient, &10, &500, &50, &3); + assert_eq!(sid, 0); + + let stream = client.get_stream(&sid); + assert_eq!(stream.payer, payer); + assert_eq!(stream.recipient, recipient); + assert_eq!(stream.deposited, 500); + assert_eq!(stream.rate_per_ledger, 10); + + let status = client.get_recurring_status(&sid); + assert_eq!(status.cycles_completed, 0); + assert_eq!(status.cycles_remaining, 3); +} + +#[test] +fn test_advance_recurring_stream_deducts_next_cycle() { + let env = Env::default(); + let (_, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 10_000); + let token = token::Client::new(&env, &token_id); + + let start = env.ledger().sequence(); + let sid = client.open_recurring_stream(&token_id, &payer, &recipient, &10, &500, &50, &3); + + // Drain the first cycle (10/ledger * 50 ledgers = 500). + advance_ledger(&env, start + 50); + let claimed = client.claim_stream(&sid, &recipient); + assert_eq!(claimed, 500); + + // Advance into cycle 2: the next deposit is deducted from the payer. + client.advance_recurring_stream(&sid); + let stream = client.get_stream(&sid); + assert_eq!(stream.deposited, 1_000); + + let status = client.get_recurring_status(&sid); + assert_eq!(status.cycles_completed, 1); + assert_eq!(status.cycles_remaining, 2); + + // Payer has been debited for two cycles (500 + 500). + assert_eq!(token.balance(&payer), 9_000); +} + +#[test] +fn test_recurring_stream_stops_after_max_cycles() { + let env = Env::default(); + let (_, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 10_000); + let token = token::Client::new(&env, &token_id); + + let start = env.ledger().sequence(); + let sid = client.open_recurring_stream(&token_id, &payer, &recipient, &10, &500, &50, &2); + + // Cycle 1. + advance_ledger(&env, start + 50); + client.claim_stream(&sid, &recipient); + client.advance_recurring_stream(&sid); + + // Cycle 2. + advance_ledger(&env, start + 100); + client.claim_stream(&sid, &recipient); + client.advance_recurring_stream(&sid); + + let status = client.get_recurring_status(&sid); + assert_eq!(status.cycles_completed, 2); + assert_eq!(status.cycles_remaining, 0); + + // No further advance is possible once max_cycles is reached. + assert!(client.try_advance_recurring_stream(&sid).is_err()); + assert_eq!(token.balance(&recipient), 1_000); +} + +#[test] +fn test_advance_recurring_stream_requires_depleted_cycle() { + let env = Env::default(); + let (_, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 5_000); + let sid = client.open_recurring_stream(&token_id, &payer, &recipient, &10, &500, &50, &3); + + // Nothing claimed yet, so the first cycle is not depleted. + assert!(client.try_advance_recurring_stream(&sid).is_err()); +} + +#[test] +fn test_open_recurring_stream_emits_events() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 5_000); + client.open_recurring_stream(&token_id, &payer, &recipient, &10, &500, &50, &3); + + // stream_open + recurring_cycle_start. + let events = env.events().all().filter_by_contract(&contract_id); + assert_eq!(events.events().len(), 2); +} + +#[test] +fn test_advance_recurring_stream_emits_cycle_events() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 10_000); + let start = env.ledger().sequence(); + let sid = client.open_recurring_stream(&token_id, &payer, &recipient, &10, &500, &50, &3); + + advance_ledger(&env, start + 50); + client.claim_stream(&sid, &recipient); + client.advance_recurring_stream(&sid); + + // recurring_cycle_end (cycle 1) + recurring_cycle_start (cycle 2). + let events = env.events().all().filter_by_contract(&contract_id); + assert_eq!(events.events().len(), 2); +} + +#[test] +fn test_get_recurring_status_on_plain_stream_panics() { + let env = Env::default(); + let (_, client) = deploy(&env); + let admin = client.get_admin(); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + let token_id = create_token(&env, &admin, &payer, 5_000); + let sid = client.open_stream(&token_id, &payer, &recipient, &10, &500); + + assert!(client.try_get_recurring_status(&sid).is_err()); +} + // ─── MultiSig Flow ─────────────────────────────────────────────────────────── #[test] @@ -780,7 +995,9 @@ fn test_create_multisig_emits_event() { let mut signers = Vec::new(&env); signers.push_back(s1.clone()); let expiry = env.ledger().sequence() + 1000; - client.create_multisig(&token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry); + client.create_multisig( + &token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry, + ); let events = env.events().all().filter_by_contract(&contract_id); assert_eq!(events.events().len(), 1); @@ -801,8 +1018,12 @@ fn test_multisig_count() { signers.push_back(s1.clone()); let expiry = env.ledger().sequence() + 1000; - client.create_multisig(&token_id, &proposer, &recipient, &1_000, &1, &signers, &expiry); - client.create_multisig(&token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry); + client.create_multisig( + &token_id, &proposer, &recipient, &1_000, &1, &signers, &expiry, + ); + client.create_multisig( + &token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry, + ); assert_eq!(client.get_multisig_count(), 2); } @@ -1001,7 +1222,14 @@ fn test_view_functions_return_correct_data() { assert_eq!(stream.payer, from); let release = env.ledger().sequence() + 100; - let eid = client.create_escrow(&token_id, &from, &to, &1_000, &release, &Symbol::new(&env, "v")); + let eid = client.create_escrow( + &token_id, + &from, + &to, + &1_000, + &release, + &Symbol::new(&env, "v"), + ); let escrow = client.get_escrow(&eid); assert_eq!(escrow.id, eid); assert_eq!(escrow.amount, 1_000); @@ -1034,7 +1262,14 @@ fn test_get_contract_stats() { let token_id = create_token(&env, &admin, &from, 10_000); let release = env.ledger().sequence() + 100; - client.create_escrow(&token_id, &from, &to, &1_000, &release, &Symbol::new(&env, "")); + client.create_escrow( + &token_id, + &from, + &to, + &1_000, + &release, + &Symbol::new(&env, ""), + ); client.open_stream(&token_id, &from, &to, &10, &500); let mut signers = Vec::new(&env); @@ -1200,8 +1435,7 @@ fn test_initiate_emergency_withdrawal() { signers.push_back(signer2.clone()); // Rotate to a 2-of-2 signer set via the multi-sig path (threshold-1 deploy // auto-executes on propose). - let data: Vec = - Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); + let data: Vec = Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); client.propose_admin_action(&admin, &Symbol::new(&env, "set_admin_signers"), &data); let token_id = create_token(&env, &admin, &contract_id, 5_000); @@ -1228,8 +1462,7 @@ fn test_approve_emergency_withdrawal() { signers.push_back(signer2.clone()); // Rotate to a 2-of-2 signer set via the multi-sig path (threshold-1 deploy // auto-executes on propose). - let data: Vec = - Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); + let data: Vec = Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); client.propose_admin_action(&admin, &Symbol::new(&env, "set_admin_signers"), &data); let token_id = create_token(&env, &admin, &contract_id, 5_000); @@ -1239,5 +1472,3 @@ fn test_approve_emergency_withdrawal() { let withdrawal = client.get_emergency_withdrawal(&wid); assert_eq!(withdrawal.approvals.len(), 1); } - -