diff --git a/contracts/stream/src/delegation.rs b/contracts/stream/src/delegation.rs index a01cbe9b..2c2d7c11 100644 --- a/contracts/stream/src/delegation.rs +++ b/contracts/stream/src/delegation.rs @@ -20,7 +20,7 @@ use soroban_sdk::Env; -use crate::{load_stream, load_delegated_nonce, ContractError}; +use crate::{load_delegated_nonce, load_stream, ContractError}; /// Validate the delegation parameters for a delegated-withdraw call. /// @@ -64,12 +64,12 @@ mod tests { extern crate std; use super::*; + use crate::{FluxoraStream, FluxoraStreamClient, StreamKind}; use soroban_sdk::{ testutils::{Address as _, Ledger}, token::Client as TokenClient, Address, Env, }; - use crate::{FluxoraStream, FluxoraStreamClient, StreamKind}; /// Set up a minimal contract environment and return (env, client, stream_id, recipient). fn setup() -> (Env, FluxoraStreamClient<'static>, u64, Address) { diff --git a/contracts/stream/src/lib.rs b/contracts/stream/src/lib.rs index f3367bbb..15374cc3 100644 --- a/contracts/stream/src/lib.rs +++ b/contracts/stream/src/lib.rs @@ -12,6 +12,7 @@ mod token_check; pub mod types; #[cfg(test)] mod delegation; +mod token_check; use events::*; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Map}; diff --git a/contracts/stream/tests/balance_conservation.rs b/contracts/stream/tests/balance_conservation.rs index a77d75c6..e3c8395c 100644 --- a/contracts/stream/tests/balance_conservation.rs +++ b/contracts/stream/tests/balance_conservation.rs @@ -73,7 +73,9 @@ impl TestContext { let contract_id = env.register_contract(None, FluxoraStream); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); let admin = Address::generate(&env); let sender = Address::generate(&env); @@ -88,12 +90,7 @@ impl TestContext { StellarAssetClient::new(&env, &token_id).mint(&recipient, &1_000_000_000_000); // Approve the contract to pull arbitrary top-up amounts from the sender. - TokenClient::new(&env, &token_id).approve( - &sender, - &contract_id, - &i128::MAX, - &1_000_000u32, - ); + TokenClient::new(&env, &token_id).approve(&sender, &contract_id, &i128::MAX, &1_000_000u32); env.ledger().set_timestamp(0); @@ -292,7 +289,8 @@ fn assert_invariants( .saturating_add(ctx.recipient_balance()) .saturating_add(ctx.contract_balance()); assert_eq!( - total_outside, INITIAL_MINT, + total_outside, + INITIAL_MINT, "{label}: global token conservation violated: sender={} recipient={} contract={}", ctx.sender_balance(), ctx.recipient_balance(), @@ -579,7 +577,10 @@ fn regression_cliff_only_unsupported_mutations() { assert_eq!(ctx.client().get_withdrawable(&id), 1000); let withdrawn = ctx.client().withdraw(&id); assert_eq!(withdrawn, 1000); - assert_eq!(ctx.client().get_stream_state(&id).status, StreamStatus::Completed); + assert_eq!( + ctx.client().get_stream_state(&id).status, + StreamStatus::Completed + ); } /// Completed streams must report a deterministic `deposit_amount` accrual @@ -592,7 +593,10 @@ fn regression_completed_stream_accrual_is_deterministic() { ctx.env.ledger().set_timestamp(1000); ctx.env.ledger().set_sequence_number(1000); ctx.client().withdraw(&id); - assert_eq!(ctx.client().get_stream_state(&id).status, StreamStatus::Completed); + assert_eq!( + ctx.client().get_stream_state(&id).status, + StreamStatus::Completed + ); for t in [0u64, 500, 1000, 10_000, u64::MAX] { ctx.env.ledger().set_timestamp(t); diff --git a/contracts/stream/tests/batch_withdraw_to_auth.rs b/contracts/stream/tests/batch_withdraw_to_auth.rs new file mode 100644 index 00000000..63ea47cc --- /dev/null +++ b/contracts/stream/tests/batch_withdraw_to_auth.rs @@ -0,0 +1,378 @@ +extern crate std; + +use fluxora_stream::{ + ContractError, FluxoraStream, FluxoraStreamClient, StreamKind, WithdrawToParam, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +struct TestContext<'a> { + env: Env, + contract_id: Address, + sender: Address, + recipient: Address, + token: TokenClient<'a>, +} + +impl<'a> TestContext<'a> { + fn setup() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, FluxoraStream); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let client = FluxoraStreamClient::new(&env, &contract_id); + client.init(&token_id, &admin); + + let sac = StellarAssetClient::new(&env, &token_id); + sac.mint(&sender, &10_000_i128); + + let token = TokenClient::new(&env, &token_id); + token.approve(&sender, &contract_id, &i128::MAX, &100_000); + + TestContext { + env, + contract_id, + sender, + recipient, + token, + } + } + + fn setup_strict() -> Self { + let env = Env::default(); + let contract_id = env.register_contract(None, FluxoraStream); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let client = FluxoraStreamClient::new(&env, &contract_id); + use soroban_sdk::{testutils::MockAuth, testutils::MockAuthInvoke, IntoVal}; + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "init", + args: (&token_id, &admin).into_val(&env), + sub_invokes: &[], + }, + }]); + client.init(&token_id, &admin); + + let sac = StellarAssetClient::new(&env, &token_id); + env.mock_auths(&[MockAuth { + address: &token_admin, + invoke: &MockAuthInvoke { + contract: &token_id, + fn_name: "mint", + args: (&sender, 10_000_i128).into_val(&env), + sub_invokes: &[], + }, + }]); + sac.mint(&sender, &10_000_i128); + + env.mock_auths(&[MockAuth { + address: &sender, + invoke: &MockAuthInvoke { + contract: &token_id, + fn_name: "approve", + args: (&sender, &contract_id, i128::MAX, 100_000u32).into_val(&env), + sub_invokes: &[], + }, + }]); + TokenClient::new(&env, &token_id).approve(&sender, &contract_id, &i128::MAX, &100_000); + + TestContext { + env: env.clone(), + contract_id, + sender, + recipient, + token: TokenClient::new(&env, &token_id), + } + } + + fn client(&self) -> FluxoraStreamClient<'_> { + FluxoraStreamClient::new(&self.env, &self.contract_id) + } +} + +#[test] +fn test_batch_withdraw_to_requires_recipient_auth() { + let ctx = TestContext::setup_strict(); + use soroban_sdk::{testutils::MockAuth, testutils::MockAuthInvoke, IntoVal}; + + ctx.env.mock_auths(&[MockAuth { + address: &ctx.sender, + invoke: &MockAuthInvoke { + contract: &ctx.contract_id, + fn_name: "create_stream", + args: ( + &ctx.sender, + &ctx.recipient, + 1000_i128, + 1_i128, + 0u64, + 0u64, + 1000u64, + 0i128, + Option::::None, + StreamKind::Linear, + ) + .into_val(&ctx.env), + sub_invokes: &[], + }, + }]); + let stream_id = ctx.client().create_stream( + &ctx.sender, + &ctx.recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, + &StreamKind::Linear, + ); + + ctx.env.ledger().set_timestamp(500); + let destination = Address::generate(&ctx.env); + let withdrawals = soroban_sdk::vec![ + &ctx.env, + WithdrawToParam { + stream_id, + destination: destination.clone(), + }, + ]; + + let batch_args = (&ctx.recipient, withdrawals.clone()).into_val(&ctx.env); + ctx.env.mock_auths(&[MockAuth { + address: &ctx.recipient, + invoke: &MockAuthInvoke { + contract: &ctx.contract_id, + fn_name: "batch_withdraw_to", + args: batch_args, + sub_invokes: &[], + }, + }]); + let results = ctx.client().batch_withdraw_to(&ctx.recipient, &withdrawals); + + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().amount, 500); + assert_eq!(ctx.token.balance(&destination), 500); +} + +#[test] +fn test_batch_withdraw_to_mixed_recipients_reverts_atomically() { + let ctx = TestContext::setup_strict(); + use soroban_sdk::{testutils::MockAuth, testutils::MockAuthInvoke, IntoVal}; + let other_recipient = Address::generate(&ctx.env); + + ctx.env.mock_auths(&[MockAuth { + address: &ctx.sender, + invoke: &MockAuthInvoke { + contract: &ctx.contract_id, + fn_name: "create_stream", + args: ( + &ctx.sender, + &ctx.recipient, + 1000_i128, + 1_i128, + 0u64, + 0u64, + 1000u64, + 0i128, + Option::::None, + StreamKind::Linear, + ) + .into_val(&ctx.env), + sub_invokes: &[], + }, + }]); + let stream_id_a = ctx.client().create_stream( + &ctx.sender, + &ctx.recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, + &StreamKind::Linear, + ); + ctx.env.mock_auths(&[MockAuth { + address: &ctx.sender, + invoke: &MockAuthInvoke { + contract: &ctx.contract_id, + fn_name: "create_stream", + args: ( + &ctx.sender, + &other_recipient, + 1000_i128, + 1_i128, + 0u64, + 0u64, + 1000u64, + 0i128, + Option::::None, + StreamKind::Linear, + ) + .into_val(&ctx.env), + sub_invokes: &[], + }, + }]); + let stream_id_b = ctx.client().create_stream( + &ctx.sender, + &other_recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, + &StreamKind::Linear, + ); + + ctx.env.ledger().set_timestamp(500); + let withdrawals = soroban_sdk::vec![ + &ctx.env, + WithdrawToParam { + stream_id: stream_id_a, + destination: Address::generate(&ctx.env), + }, + WithdrawToParam { + stream_id: stream_id_b, + destination: Address::generate(&ctx.env), + }, + ]; + + let batch_args = (&ctx.recipient, withdrawals.clone()).into_val(&ctx.env); + ctx.env.mock_auths(&[MockAuth { + address: &ctx.recipient, + invoke: &MockAuthInvoke { + contract: &ctx.contract_id, + fn_name: "batch_withdraw_to", + args: batch_args, + sub_invokes: &[], + }, + }]); + + let result = ctx + .client() + .try_batch_withdraw_to(&ctx.recipient, &withdrawals); + + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); + assert_eq!( + ctx.client().get_stream_state(&stream_id_a).withdrawn_amount, + 0 + ); + assert_eq!( + ctx.client().get_stream_state(&stream_id_b).withdrawn_amount, + 0 + ); +} + +#[test] +fn test_batch_withdraw_to_duplicate_destinations_aggregate_transfers() { + let ctx = TestContext::setup(); + let stream_id_a = ctx.client().create_stream( + &ctx.sender, + &ctx.recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, + &StreamKind::Linear, + ); + let stream_id_b = ctx.client().create_stream( + &ctx.sender, + &ctx.recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, + &StreamKind::Linear, + ); + + ctx.env.ledger().set_timestamp(500); + let destination = Address::generate(&ctx.env); + let withdrawals = soroban_sdk::vec![ + &ctx.env, + WithdrawToParam { + stream_id: stream_id_a, + destination: destination.clone(), + }, + WithdrawToParam { + stream_id: stream_id_b, + destination: destination.clone(), + }, + ]; + + let results = ctx.client().batch_withdraw_to(&ctx.recipient, &withdrawals); + + assert_eq!(results.len(), 2); + assert_eq!(results.get(0).unwrap().amount, 500); + assert_eq!(results.get(1).unwrap().amount, 500); + assert_eq!(ctx.token.balance(&destination), 1000); +} + +#[test] +fn test_batch_withdraw_to_rejects_contract_destination() { + let ctx = TestContext::setup(); + let stream_id = ctx.client().create_stream( + &ctx.sender, + &ctx.recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, + &StreamKind::Linear, + ); + + ctx.env.ledger().set_timestamp(500); + let withdrawals = soroban_sdk::vec![ + &ctx.env, + WithdrawToParam { + stream_id, + destination: ctx.contract_id.clone(), + }, + ]; + + let result = ctx + .client() + .try_batch_withdraw_to(&ctx.recipient, &withdrawals); + + assert_eq!(result, Err(Ok(ContractError::InvalidParams))); + assert_eq!( + ctx.client().get_stream_state(&stream_id).withdrawn_amount, + 0 + ); + assert_eq!(ctx.token.balance(&ctx.contract_id), 1000); +} diff --git a/contracts/stream/tests/clone_stream.rs b/contracts/stream/tests/clone_stream.rs index 5cb5e02a..1b14c970 100644 --- a/contracts/stream/tests/clone_stream.rs +++ b/contracts/stream/tests/clone_stream.rs @@ -19,9 +19,9 @@ use fluxora_stream::{ StreamKind, StreamStatus, }; use soroban_sdk::{ - testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke, Events, LedgerInfo}, + testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, token::{Client as TokenClient, StellarAssetClient}, - Address, Env, IntoVal, Symbol, TryFromVal, FromVal, + Address, Env, FromVal, IntoVal, Symbol, TryFromVal, }; // --------------------------------------------------------------------------- @@ -470,7 +470,15 @@ fn clone_sender_authorized_strict() { max_entry_ttl: 6312000, }); let source_id = client.create_stream( - &sender, &recipient, &1000_i128, &1_i128, &0u64, &0u64, &1000u64, &0, &None, + &sender, + &recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, &StreamKind::Linear, ); @@ -520,7 +528,15 @@ fn clone_recipient_unauthorized() { env.ledger().set_timestamp(0); let source_id = client.create_stream( - &sender, &recipient, &1000_i128, &1_i128, &0u64, &0u64, &1000u64, &0, &None, + &sender, + &recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, &StreamKind::Linear, ); @@ -567,7 +583,15 @@ fn clone_third_party_unauthorized() { env.ledger().set_timestamp(0); let source_id = client.create_stream( - &sender, &recipient, &1000_i128, &1_i128, &0u64, &0u64, &1000u64, &0, &None, + &sender, + &recipient, + &1000_i128, + &1_i128, + &0u64, + &0u64, + &1000u64, + &0, + &None, &StreamKind::Linear, ); @@ -2063,8 +2087,6 @@ fn clone_override_cliff_only_inherits_zero_rate() { &StreamKind::CliffOnly, ); - - let snap = StreamSnapshot::capture(&ctx, source_id); ctx.env.ledger().set_timestamp(1000); @@ -2130,7 +2152,7 @@ fn clone_override_cliff_offset_overflow_rejected() { &source_id, &ctx.recipient, &overflow_start, - &u64::MAX, // end_time, irrelevant if cliff overflows first + &u64::MAX, // end_time, irrelevant if cliff overflows first &1000_i128, &false, ); @@ -2142,4 +2164,3 @@ fn clone_override_cliff_offset_overflow_rejected() { ); snap.assert_unchanged(&ctx, source_id); } - diff --git a/contracts/stream/tests/factory_policy.rs b/contracts/stream/tests/factory_policy.rs index 51922153..9593db33 100644 --- a/contracts/stream/tests/factory_policy.rs +++ b/contracts/stream/tests/factory_policy.rs @@ -385,7 +385,6 @@ fn test_create_stream_recipient_not_allowlisted() { assert_eq!(result, Err(Ok(FactoryError::RecipientNotAllowlisted))); } - #[test] fn test_create_stream_supports_cliff_only_and_memo() { let ctx = Ctx::setup(); @@ -452,8 +451,14 @@ fn test_create_streams_batch_allows_all_valid_entries_atomically() { let ids = result.unwrap().unwrap(); assert_eq!(ids.len(), 2); - assert_eq!(ctx.stream.get_stream_memo(&ids.get_unchecked(0)).unwrap(), Bytes::from_slice(&ctx.env, b"batch-1")); - assert_eq!(ctx.stream.get_stream_memo(&ids.get_unchecked(1)).unwrap(), Bytes::from_slice(&ctx.env, b"batch-2")); + assert_eq!( + ctx.stream.get_stream_memo(&ids.get_unchecked(0)).unwrap(), + Bytes::from_slice(&ctx.env, b"batch-1") + ); + assert_eq!( + ctx.stream.get_stream_memo(&ids.get_unchecked(1)).unwrap(), + Bytes::from_slice(&ctx.env, b"batch-2") + ); } #[test] diff --git a/contracts/stream/tests/formal_verification_smoke.rs b/contracts/stream/tests/formal_verification_smoke.rs index 5abfb631..009fef0b 100644 --- a/contracts/stream/tests/formal_verification_smoke.rs +++ b/contracts/stream/tests/formal_verification_smoke.rs @@ -61,9 +61,7 @@ mod kani_fee { kani::assume(bps <= 10_000); // Exact production expression - let _ = gross - .checked_mul(bps as i128) - .map(|v| v / 10_000); + let _ = gross.checked_mul(bps as i128).map(|v| v / 10_000); } } diff --git a/contracts/stream/tests/id_range.rs b/contracts/stream/tests/id_range.rs index 1fb986ef..1ee21c58 100644 --- a/contracts/stream/tests/id_range.rs +++ b/contracts/stream/tests/id_range.rs @@ -60,7 +60,12 @@ impl Ctx { // Safety: env lives as long as the returned Ctx; we only hold one Ctx at a time. let client: FluxoraStreamClient<'static> = unsafe { core::mem::transmute(client) }; - Ctx { env, client, sender, recipient } + Ctx { + env, + client, + sender, + recipient, + } } /// Create one minimal stream for `self.recipient` and return its ID. @@ -143,7 +148,9 @@ fn test_get_streams_by_id_range_limit_clamping() { // Request limit 105, which is above MAX_PAGE_SIZE (100). It must be clamped. let over_cap_limit = MAX_PAGE_SIZE + 5; - let streams = ctx.client.get_streams_by_id_range(&1, &(MAX_PAGE_SIZE + 10), &over_cap_limit); + let streams = ctx + .client + .get_streams_by_id_range(&1, &(MAX_PAGE_SIZE + 10), &over_cap_limit); assert_eq!( streams.len(), @@ -185,9 +192,5 @@ fn test_get_streams_by_id_range_zero_limit() { ctx.create_n(3); let streams = ctx.client.get_streams_by_id_range(&1, &3, &0); - assert_eq!( - streams.len(), - 0, - "Limit of 0 must return an empty list" - ); + assert_eq!(streams.len(), 0, "Limit of 0 must return an empty list"); } diff --git a/contracts/stream/tests/recipient_paged_index.rs b/contracts/stream/tests/recipient_paged_index.rs index 18a65258..6f3e034a 100644 --- a/contracts/stream/tests/recipient_paged_index.rs +++ b/contracts/stream/tests/recipient_paged_index.rs @@ -61,7 +61,12 @@ impl Ctx { // Safety: env lives as long as the returned Ctx; we only hold one Ctx at a time. let client: FluxoraStreamClient<'static> = unsafe { core::mem::transmute(client) }; - Ctx { env, client, sender, recipient } + Ctx { + env, + client, + sender, + recipient, + } } /// Create one minimal stream for `self.recipient` and return its ID. @@ -177,9 +182,9 @@ fn test_bounded_call_returns_first_page() { ctx.create_n(MAX_RECIPIENT_PAGE_SIZE + 10); let bounded = ctx.client.get_recipient_streams(&ctx.recipient); - let page = ctx - .client - .get_recipient_streams_paginated(&ctx.recipient, &0, &MAX_RECIPIENT_PAGE_SIZE); + let page = + ctx.client + .get_recipient_streams_paginated(&ctx.recipient, &0, &MAX_RECIPIENT_PAGE_SIZE); assert_eq!(bounded.len(), page.stream_ids.len()); for i in 0..bounded.len() { @@ -200,9 +205,11 @@ fn test_paginated_covers_all_streams() { let mut all_ids = soroban_sdk::Vec::new(&ctx.env); let mut cursor = 0u64; loop { - let page = ctx - .client - .get_recipient_streams_paginated(&ctx.recipient, &cursor, &MAX_RECIPIENT_PAGE_SIZE); + let page = ctx.client.get_recipient_streams_paginated( + &ctx.recipient, + &cursor, + &MAX_RECIPIENT_PAGE_SIZE, + ); for i in 0..page.stream_ids.len() { all_ids.push_back(page.stream_ids.get(i).unwrap()); } @@ -212,7 +219,11 @@ fn test_paginated_covers_all_streams() { } } - assert_eq!(all_ids.len(), total, "pagination must enumerate every stream"); + assert_eq!( + all_ids.len(), + total, + "pagination must enumerate every stream" + ); } // --------------------------------------------------------------------------- diff --git a/contracts/stream/tests/top_up_boundary.rs b/contracts/stream/tests/top_up_boundary.rs index a273bebc..bddefde0 100644 --- a/contracts/stream/tests/top_up_boundary.rs +++ b/contracts/stream/tests/top_up_boundary.rs @@ -1,6 +1,8 @@ extern crate std; -use fluxora_stream::{ContractError, FluxoraStream, FluxoraStreamClient, PauseReason, StreamKind, StreamStatus}; +use fluxora_stream::{ + ContractError, FluxoraStream, FluxoraStreamClient, PauseReason, StreamKind, StreamStatus, +}; use soroban_sdk::{ testutils::{Address as _, Ledger}, token::{Client as TokenClient, StellarAssetClient}, @@ -80,8 +82,7 @@ fn test_top_up_active_stream_deposit_reflected() { let stream_id = ctx.create_default_stream(); ctx.env.ledger().set_timestamp(100); - ctx.client - .top_up_stream(&stream_id, &ctx.sender, &500_i128); + ctx.client.top_up_stream(&stream_id, &ctx.sender, &500_i128); let state = ctx.client.get_stream_state(&stream_id); assert_eq!(state.deposit_amount, 1_500); @@ -107,8 +108,7 @@ fn test_top_up_paused_stream_matches_spec() { assert_eq!(state.status, StreamStatus::Paused); // Top up while paused - ctx.client - .top_up_stream(&stream_id, &ctx.sender, &300_i128); + ctx.client.top_up_stream(&stream_id, &ctx.sender, &300_i128); let state = ctx.client.get_stream_state(&stream_id); assert_eq!(state.deposit_amount, 1_300); @@ -182,8 +182,7 @@ fn test_top_up_near_end_updates_accrual() { assert_eq!(accrued_before, 999); // Top up by 500 → deposit becomes 1500 - ctx.client - .top_up_stream(&stream_id, &ctx.sender, &500_i128); + ctx.client.top_up_stream(&stream_id, &ctx.sender, &500_i128); let state = ctx.client.get_stream_state(&stream_id); assert_eq!(state.deposit_amount, 1_500); diff --git a/docs/streaming.md b/docs/streaming.md index 76d06b1b..525e0f92 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -994,6 +994,10 @@ These guarantees are limited to `create_streams` creation semantics. They do not `batch_withdraw(recipient, stream_ids)` enforces recipient-only authorization and deterministic completion semantics: +### batch_withdraw_to: Per-Entry Destination Routing with Atomic Reversion + +`batch_withdraw_to(recipient, withdrawals)` extends the same recipient-only authorization model to per-stream destinations. The recipient must authorize the batch, every stream must belong to that recipient, and the batch reverts atomically if any entry is unauthorized, invalid, or otherwise rejected. Destination addresses may be any non-contract address; routing to the contract address is rejected with `ContractError::InvalidParams`. + #### Non-Empty Batch Semantics - Auth boundary: only the stream `recipient` can authorize `batch_withdraw`.