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);
}
-
-