diff --git a/src/base.cairo b/src/base.cairo index 422d669..f989bef 100644 --- a/src/base.cairo +++ b/src/base.cairo @@ -1,2 +1,3 @@ pub mod errors; +pub mod helpers; pub mod types; diff --git a/src/base/errors.cairo b/src/base/errors.cairo index 7cf4efb..f06fd36 100644 --- a/src/base/errors.cairo +++ b/src/base/errors.cairo @@ -47,5 +47,8 @@ pub mod Errors { /// Thrown when wrong recipient or delegate pub const WRONG_RECIPIENT_OR_DELEGATE: felt252 = 'WRONG_RECIPIENT_OR_DELEGATE'; + + /// @notice Thrown when trying to withdraw an amount greater than the withdrawable amount. + pub const OVERDRAW: felt252 = 'Error: Overdraw'; } diff --git a/src/base/helpers.cairo b/src/base/helpers.cairo new file mode 100644 index 0000000..c83f1de --- /dev/null +++ b/src/base/helpers.cairo @@ -0,0 +1,25 @@ +pub mod Helpers { + use core::num::traits::Pow; + + /// @dev Descales the provided `amount` from 18 decimals fixed-point number to token's decimals + /// number. + pub fn descale_amount(amount: u256, decimals: u8) -> u256 { + if decimals == 18 { + return amount; + } + + let scale_factor = 10_u256.pow(18 - decimals.into()); + return amount / scale_factor; + } + + /// @dev Scales the provided `amount` from token's decimals number to 18 decimals fixed-point + /// number. + pub fn scale_amount(amount: u256, decimals: u8) -> u256 { + if decimals == 18 { + return amount; + } + + let scale_factor = 10_u256.pow(18 - decimals.into()); + return amount * scale_factor; + } +} diff --git a/src/base/types.cairo b/src/base/types.cairo index 11a4d00..3646b73 100644 --- a/src/base/types.cairo +++ b/src/base/types.cairo @@ -16,7 +16,8 @@ pub struct Stream { pub balance: u256, pub status: StreamStatus, pub rate_per_second: UFixedPoint123x128, - pub last_update_time: u64, + pub snapshot_debt_scaled: u256, + pub snapshot_time: u64, pub transferable: bool, } @@ -45,7 +46,7 @@ pub struct WeightedDistribution { } /// @notice Enum representing the possible states of a stream -#[derive(Drop, Serde, starknet::Store, PartialEq)] +#[derive(Drop, Serde, starknet::Store, PartialEq, Debug)] #[allow(starknet::store_no_default_variant)] pub enum StreamStatus { Active, // Stream is actively streaming tokens diff --git a/src/interfaces/IPaymentStream.cairo b/src/interfaces/IPaymentStream.cairo index f9cf7cb..a16b426 100644 --- a/src/interfaces/IPaymentStream.cairo +++ b/src/interfaces/IPaymentStream.cairo @@ -1,6 +1,6 @@ use fp::UFixedPoint123x128; use starknet::ContractAddress; -use crate::base::types::{ProtocolMetrics, Stream, StreamMetrics}; +use crate::base::types::{ProtocolMetrics, Stream, StreamMetrics, StreamStatus}; /// @title IPaymentStream /// @notice Creates and manages payment streams with linear streaming functions. @@ -192,10 +192,6 @@ pub trait IPaymentStream { /// @param transferable Boolean indicating if the stream can be transferred fn set_transferability(ref self: TContractState, stream_id: u256, transferable: bool); - /// @notice Checks if the stream is transferable - /// @param stream_id The ID of the stream to check - /// @return Boolean indicating if the stream is transferable - fn is_transferable(self: @TContractState, stream_id: u256) -> bool; /// @notice Gets the protocol fee of the token /// @param token The ContractAddress of the token /// @return u256 The fee of the token @@ -310,6 +306,32 @@ pub trait IPaymentStream { /// @return Boolean indicating if the refund and pause operation was successful fn refund_and_pause(ref self: TContractState, stream_id: u256, amount: u256) -> bool; + /// @notice Returns the stream's status. + /// @dev Reverts if `stream_id` references a null stream. + /// Integrators should exercise caution when depending on the return value of this function as + /// streams can be paused and resumed at any moment. + /// @param stream_id The stream ID for the query. + fn status_of(self: @TContractState, stream_id: u256) -> StreamStatus; + + /// @notice Returns the amount of debt accrued since the snapshot time until now, denoted as a + /// fixed-point number where 1e18 is 1 token. + /// @dev Reverts if `stream_id` references a null stream. + /// @param stream_id The stream ID for the query. + fn ongoing_debt_scaled_of(self: @TContractState, stream_id: u256) -> u256; + + /// @notice Calculates the amount that the recipient can withdraw from the stream, denoted in + /// token decimals. + /// @dev Reverts if `stream_id` references a null stream. + /// @param stream_id The stream ID for the query. + /// @return withdrawableAmount The amount that the recipient can withdraw. + fn covered_debt_of(self: @TContractState, stream_id: u256) -> u128; + + /// @notice Returns the amount of debt not covered by the stream balance, denoted in token's + /// decimals. + /// @dev Reverts if `stream_id` references a null stream. + /// @param stream_id The stream ID for the query. + fn uncovered_debt_of(self: @TContractState, stream_id: u256) -> u256; + /// @notice Retrieves the sum of balances of all streams /// @param token The ERC-20 token to query /// @return The aggregated balance across all streams diff --git a/src/payment_stream.cairo b/src/payment_stream.cairo index 15672c5..a9b8a7f 100644 --- a/src/payment_stream.cairo +++ b/src/payment_stream.cairo @@ -18,9 +18,10 @@ pub mod PaymentStream { use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; use crate::base::errors::Errors::{ DECIMALS_TOO_HIGH, END_BEFORE_START, INSUFFICIENT_ALLOWANCE, INVALID_RECIPIENT, - INVALID_TOKEN, NON_TRANSFERABLE_STREAM, TOO_SHORT_DURATION, UNEXISTING_STREAM, - WRONG_RECIPIENT, WRONG_RECIPIENT_OR_DELEGATE, WRONG_SENDER, ZERO_AMOUNT, + INVALID_TOKEN, NON_TRANSFERABLE_STREAM, OVERDRAW, TOO_SHORT_DURATION, UNEXISTING_STREAM, + WRONG_RECIPIENT_OR_DELEGATE, WRONG_SENDER, ZERO_AMOUNT, }; + use crate::base::helpers::Helpers; use crate::base::types::{ProtocolMetrics, Stream, StreamMetrics, StreamStatus}; component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent); @@ -195,6 +196,8 @@ pub mod PaymentStream { stream_id: u256, new_recipient: ContractAddress, } + + #[derive(Drop, starknet::Event)] struct ProtocolFeeSet { #[key] token: ContractAddress, @@ -349,8 +352,7 @@ pub mod PaymentStream { assert(duration >= 1, TOO_SHORT_DURATION); let rate_per_second = self.calculate_stream_rate(total_amount, duration); - let erc20_dispatcher = IERC20MetadataDispatcher { contract_address: token }; - let token_decimals = erc20_dispatcher.decimals(); + let token_decimals = IERC20MetadataDispatcher { contract_address: token }.decimals(); assert(token_decimals <= 18, DECIMALS_TOO_HIGH); // Create new stream @@ -368,7 +370,8 @@ pub mod PaymentStream { cancelable, status: StreamStatus::Active, rate_per_second, - last_update_time: start_time, + snapshot_debt_scaled: 0, + snapshot_time: start_time, transferable, }; @@ -526,15 +529,70 @@ pub mod PaymentStream { fn withdraw( ref self: ContractState, stream_id: u256, amount: u256, to: ContractAddress, ) -> (u128, u128) { - let stream = self.streams.read(stream_id); + // Check: the withdraw amount is not zero. + assert(amount > 0, ZERO_AMOUNT); - // @dev Allow stream creator to withdraw funds when a stream is canceled. - if stream.sender != get_caller_address() { + // Check: the withdrawal address is not zero. + assert(to.is_non_zero(), INVALID_RECIPIENT); + + // Check: `msg.sender` is neither the stream's recipient nor an approved third party, + // the withdrawal address must be the recipient. + if to != self.erc721.owner_of(stream_id) { self.assert_is_recipient_or_delegate(stream_id); } - assert(amount > 0, ZERO_AMOUNT); - assert(to.is_non_zero(), INVALID_RECIPIENT); + let stream = self.streams.read(stream_id); + + let token_decimals = stream.token_decimals; + + // Calculate the total debt. + let total_debt_scaled = self.ongoing_debt_scaled_of(stream_id) + + stream.snapshot_debt_scaled; + let total_debt = Helpers::descale_amount(total_debt_scaled, token_decimals); + + // Calculate the withdrawable amount. + let balance = stream.balance; + let withdrawable_amount = if balance < total_debt { + // If the stream balance is less than the total debt, the withdrawable amount is the + // balance. + balance + } else { + // Otherwise, the withdrawable amount is the total debt. + total_debt + }; + + // Check: the withdraw amount is not greater than the withdrawable amount. + // assert(amount <= withdrawable_amount, OVERDRAW); + + // Calculate the amount scaled. + let amount_scaled = Helpers::scale_amount(amount, token_decimals); + + let mut snapshot_debt_scaled = stream.snapshot_debt_scaled; + let mut snapshot_time = stream.snapshot_time; + // If the amount is less than the snapshot debt, reduce it from the snapshot debt and + // leave the snapshot time unchanged. + if amount_scaled <= stream.snapshot_debt_scaled { + snapshot_debt_scaled -= amount_scaled; + } // Else reduce the amount from the ongoing debt by setting snapshot time to + // `block.timestamp` and set the snapshot debt to the remaining total debt. + else { + snapshot_debt_scaled = total_debt_scaled - amount_scaled; + // Effect: update the stream time. + snapshot_time = get_block_timestamp(); + } + + self + .streams + .write( + stream_id, + Stream { + snapshot_debt_scaled, + snapshot_time, + // Effect: update the stream balance. + total_amount: stream.total_amount - amount, + ..stream, + }, + ); let fee = self.calculate_protocol_fee(amount); let net_amount = (amount - fee); @@ -790,7 +848,7 @@ pub mod PaymentStream { // Restart the stream by setting status to active and updating rate stream.status = StreamStatus::Active; stream.rate_per_second = rate_per_second; - stream.last_update_time = starknet::get_block_timestamp(); + stream.snapshot_time = get_block_timestamp(); // Update the total amount stream.total_amount += amount; @@ -815,14 +873,52 @@ pub mod PaymentStream { } fn void(ref self: ContractState, stream_id: u256) { - let mut stream = self.streams.read(stream_id); - self.assert_stream_exists(stream_id); + + let stream = self.streams.read(stream_id); assert(stream.status != StreamStatus::Canceled, 'Stream is not active'); - stream.status = StreamStatus::Voided; - self.streams.write(stream_id, stream); + // Check: `msg.sender` is either the stream's sender, recipient or an approved third + // party. + let caller = get_caller_address(); + if caller != stream.sender { + self.assert_is_recipient_or_delegate(stream_id); + } + + let debt_to_write_off = self.uncovered_debt_of(stream_id); + + let mut snapshot_debt_scaled = stream.snapshot_debt_scaled; + // If the stream is solvent, update the total debt normally. + if debt_to_write_off == 0 { + let ongoing_debt_scaled = self.ongoing_debt_scaled_of(stream_id); + if ongoing_debt_scaled > 0 { + // Effect: Update the snapshot debt by adding the ongoing debt. + snapshot_debt_scaled += ongoing_debt_scaled; + } + } // If the stream is insolvent, write off the uncovered debt. + else { + // Effect: update the total debt by setting snapshot debt to the stream balance. + snapshot_debt_scaled = + Helpers::scale_amount(stream.total_amount, stream.token_decimals); + } + + self + .streams + .write( + stream_id, + Stream { + snapshot_debt_scaled, + // Effect: update the snapshot time. + snapshot_time: get_block_timestamp(), + // Effect: set the rate per second to zero. + rate_per_second: 0.into(), + // Effect: set the stream as voided. + status: StreamStatus::Voided, + ..stream, + }, + ); + // Log the void. self.emit(StreamVoided { stream_id }); } @@ -898,21 +994,6 @@ pub mod PaymentStream { } fn get_stream(self: @ContractState, stream_id: u256) -> Stream { - // Return dummy stream - // Stream { - // sender: starknet::contract_address_const::<0>(), - // recipient: starknet::contract_address_const::<0>(), - // token: starknet::contract_address_const::<0>(), - // total_amount: 0_u256, - // start_time: 0_u64, - // end_time: 0_u64, - // withdrawn_amount: 0_u256, - // cancelable: false, - // status: StreamStatus::Active, - // rate_per_second: 0, - // last_update_time: 0, - // } - self.streams.read(stream_id) } @@ -942,8 +1023,10 @@ pub mod PaymentStream { } fn get_total_debt(self: @ContractState, stream_id: u256) -> u256 { - // Return dummy amount - 0_u256 + let stream = self.streams.read(stream_id); + let total_debt_scaled = self.ongoing_debt_scaled_of(stream_id) + + stream.snapshot_debt_scaled; + Helpers::descale_amount(total_debt_scaled, decimals: stream.token_decimals) } fn get_uncovered_debt(self: @ContractState, stream_id: u256) -> u256 { @@ -1016,24 +1099,26 @@ pub mod PaymentStream { let stream: Stream = self.streams.read(stream_id); assert!(stream.status == StreamStatus::Active, "Stream is not active"); - let new_stream = Stream { - rate_per_second: new_rate_per_second, - sender: stream.sender, - recipient: stream.recipient, - token: stream.token, - token_decimals: stream.token_decimals, - total_amount: stream.total_amount, - balance: stream.balance, - start_time: stream.start_time, - end_time: stream.end_time, - withdrawn_amount: stream.withdrawn_amount, - cancelable: stream.cancelable, - status: stream.status, - last_update_time: starknet::get_block_timestamp(), - transferable: stream.transferable, + let ongoing_debt_scaled = self.ongoing_debt_scaled_of(stream_id); + + let snapshot_debt_scaled = if ongoing_debt_scaled > 0 { + stream.snapshot_debt_scaled + ongoing_debt_scaled + } else { + stream.snapshot_debt_scaled }; - self.streams.write(stream_id, new_stream); + self + .streams + .write( + stream_id, + Stream { + rate_per_second: new_rate_per_second, + snapshot_debt_scaled, + snapshot_time: get_block_timestamp(), + ..stream, + }, + ); + self .emit( Event::StreamRateUpdated( @@ -1046,6 +1131,7 @@ pub mod PaymentStream { ), ); } + fn get_protocol_fee(self: @ContractState, token: ContractAddress) -> u256 { self.protocol_fees.read(token) } @@ -1074,7 +1160,7 @@ pub mod PaymentStream { sent_to: to, amount: protocol_revenue, }, - ) + ); } fn set_protocol_fee( @@ -1137,6 +1223,71 @@ pub mod PaymentStream { return stream.rate_per_second; } + fn status_of(self: @ContractState, stream_id: u256) -> StreamStatus { + self.streams.read(stream_id).status + } + + /// @dev Calculates the ongoing debt, as a 18-decimals fixed point number, accrued since + /// last snapshot. Return 0 if the stream is paused or `block.timestamp` is less than or + /// equal to snapshot time. + fn ongoing_debt_scaled_of(self: @ContractState, stream_id: u256) -> u256 { + let block_timestamp = get_block_timestamp(); + let stream = self.streams.read(stream_id); + let snapshot_time = stream.snapshot_time; + + let rate_per_second: u256 = stream.rate_per_second.into(); + + // Check:if the rate per second is zero or the `block.timestamp` is less than the + // `snapshotTime`. + if rate_per_second == 0 || (block_timestamp <= snapshot_time) { + return 0; + } + + // Calculate time elapsed since the last snapshot. + let elapsed_time = block_timestamp - snapshot_time; + + // Calculate the ongoing debt scaled accrued by multiplying the elapsed time by the rate + // per second. + elapsed_time.into() * rate_per_second + } + + /// @dev Calculates the amount of covered debt by the stream balance. + fn covered_debt_of(self: @ContractState, stream_id: u256) -> u128 { + let stream = self.streams.read(stream_id); + let balance = stream.balance; + + // If the balance is zero, return zero. + if balance == 0 { + return 0; + } + + let total_debt = self.get_total_debt(stream_id); + + // If the stream balance is less than or equal to the total debt, return the stream + // balance. + if balance < total_debt { + return balance.try_into().unwrap(); + } + + // At this point, the total debt fits within `uint128`, as it is less than or equal to + // the balance. + total_debt.try_into().unwrap() + } + + /// @dev Calculates the uncovered debt. + fn uncovered_debt_of(self: @ContractState, stream_id: u256) -> u256 { + let stream = self.streams.read(stream_id); + let balance = stream.balance; + + let total_debt = self.get_total_debt(stream_id); + + if balance < total_debt { + total_debt - balance + } else { + 0 + } + } + fn aggregate_balance(self: @ContractState, token: ContractAddress) -> u256 { self.aggregate_balance.read(token) } diff --git a/tests/test_distribute_contract.cairo b/tests/test_distribute_contract.cairo index cbc4336..72505be 100644 --- a/tests/test_distribute_contract.cairo +++ b/tests/test_distribute_contract.cairo @@ -1,8 +1,5 @@ use core::num::traits::Bounded; use core::traits::Into; -use fundable::base::types::{ - Distribution, DistributionHistory, TokenStats, UserStats, WeightedDistribution, -}; use fundable::interfaces::IDistributor::{IDistributorDispatcher, IDistributorDispatcherTrait}; //use fundable::interfaces::IERC20::{IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; diff --git a/tests/test_payment_stream.cairo b/tests/test_payment_stream.cairo index 3d7b357..9e830a8 100644 --- a/tests/test_payment_stream.cairo +++ b/tests/test_payment_stream.cairo @@ -1,19 +1,23 @@ use core::traits::Into; use fp::UFixedPoint123x128; -use fundable::base::types::{Stream, StreamStatus}; +use fundable::base::helpers::Helpers; +use fundable::base::types::StreamStatus; use fundable::interfaces::IPaymentStream::{IPaymentStreamDispatcher, IPaymentStreamDispatcherTrait}; use fundable::payment_stream::PaymentStream; use openzeppelin::access::accesscontrol::interface::{ IAccessControlDispatcher, IAccessControlDispatcherTrait, }; -use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use openzeppelin::token::erc20::interface::{ + IERC20Dispatcher, IERC20DispatcherTrait, IERC20MixinDispatcher, IERC20MixinDispatcherTrait, +}; use openzeppelin::token::erc721::interface::{ IERC721Dispatcher, IERC721DispatcherTrait, IERC721MetadataDispatcher, IERC721MetadataDispatcherTrait, }; use snforge_std::{ ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, spy_events, - start_cheat_caller_address, stop_cheat_caller_address, test_address, + start_cheat_block_timestamp, start_cheat_caller_address, stop_cheat_caller_address, + test_address, }; use starknet::{ContractAddress, contract_address_const}; @@ -105,7 +109,9 @@ fn test_nft_metadata() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); let metadata = IERC721MetadataDispatcher { contract_address: payment_stream.contract_address }; @@ -138,7 +144,7 @@ fn test_successful_create_stream() { // This is the first Stream Created, so it will be 0. assert!(stream_id == 0_u256, "Stream creation failed"); let owner = erc721.owner_of(stream_id); - assert!(owner == initial_owner, "NFT not minted to initial owner"); + assert!(owner == recipient, "NFT not minted to initial owner"); } #[test] @@ -846,7 +852,9 @@ fn test_successful_refund() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Deposit to the stream payment_stream.deposit(stream_id, total_amount); @@ -884,7 +892,9 @@ fn test_successful_refund_with_wrong_address() { // Create Stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); // Check sender's initial balance @@ -916,7 +926,9 @@ fn test_successful_refund_with_overdraft() { stop_cheat_caller_address(payment_stream.contract_address); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); // This is the first Stream Created, so it will be 0. @@ -945,7 +957,9 @@ fn test_successful_refund_max() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Sender assigns a delegate. payment_stream.delegate_stream(stream_id, sender); stop_cheat_caller_address(payment_stream.contract_address); @@ -984,7 +998,9 @@ fn test_successful_refund_max_with_wrong_address() { // Create Stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); // Check sender's initial balance @@ -1016,7 +1032,9 @@ fn test_successful_refund_and_pause() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Sender assigns a delegate. payment_stream.delegate_stream(stream_id, sender); stop_cheat_caller_address(payment_stream.contract_address); @@ -1059,7 +1077,9 @@ fn test_successful_refund_and_pause_with_wrong_address() { // Create Stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); // Check sender's initial balance @@ -1091,7 +1111,9 @@ fn test_successful_refund_and_pause_with_overdraft() { stop_cheat_caller_address(payment_stream.contract_address); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); // This is the first Stream Created, so it will be 0. @@ -1122,7 +1144,7 @@ fn test_nft_transfer_and_withdrawal() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream .create_stream( - initial_owner, total_amount, start_time, end_time, cancelable, token_address, + initial_owner, total_amount, start_time, end_time, cancelable, token_address, true, ); stop_cheat_caller_address(payment_stream.contract_address); @@ -1262,7 +1284,7 @@ fn test_decimal_boundary_conditions() { // New tests for the added functions #[test] fn test_transfer_stream() { - let (token_address, sender, payment_stream) = setup(); + let (token_address, sender, payment_stream, _) = setup(); let recipient = contract_address_const::<'recipient'>(); let total_amount = 10000_u256; let start_time = 100_u64; @@ -1285,7 +1307,7 @@ fn test_transfer_stream() { #[test] fn test_set_transferability() { - let (token_address, sender, payment_stream) = setup(); + let (token_address, sender, payment_stream, _) = setup(); let recipient = contract_address_const::<'recipient'>(); let total_amount = 10000_u256; let start_time = 100_u64; @@ -1307,7 +1329,7 @@ fn test_set_transferability() { #[test] fn test_is_transferable() { - let (token_address, sender, payment_stream) = setup(); + let (token_address, sender, payment_stream, _) = setup(); let recipient = contract_address_const::<'recipient'>(); let total_amount = 10000_u256; let start_time = 100_u64; @@ -1371,7 +1393,9 @@ fn test_successful_stream_check() { let cancelable = true; let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); let is_stream = payment_stream.is_stream(stream_id); @@ -1388,7 +1412,9 @@ fn test_successful_pause_check() { let cancelable = true; let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); let is_paused = payment_stream.is_paused(stream_id); @@ -1411,7 +1437,9 @@ fn test_successful_voided_check() { let cancelable = true; let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); let is_voided = payment_stream.is_voided(stream_id); @@ -1434,7 +1462,9 @@ fn test_successful_transferrable_check() { let cancelable = true; let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); let is_transferable = payment_stream.is_transferable(stream_id); @@ -1452,7 +1482,9 @@ fn test_successful_get_sender() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); stop_cheat_caller_address(payment_stream.contract_address); @@ -1472,7 +1504,9 @@ fn test_successful_get_recipient() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); stop_cheat_caller_address(payment_stream.contract_address); @@ -1492,7 +1526,9 @@ fn test_successful_get_token() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); stop_cheat_caller_address(payment_stream.contract_address); @@ -1512,7 +1548,9 @@ fn test_successful_get_rate_per_second() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); println!("Stream ID: {}", stream_id); stop_cheat_caller_address(payment_stream.contract_address); @@ -1534,7 +1572,9 @@ fn test_deposit_successful() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Setup token approval for deposit let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; @@ -1567,7 +1607,9 @@ fn test_deposit_zero_amount() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Try to deposit zero amount - should panic payment_stream.deposit(stream_id, deposit_amount); @@ -1589,7 +1631,9 @@ fn test_deposit_to_voided_stream() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Void the stream payment_stream.void(stream_id); @@ -1630,7 +1674,9 @@ fn test_deposit_to_canceled_stream() { // Create and cancel stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); payment_stream.cancel(stream_id); // Setup token approval @@ -1659,7 +1705,9 @@ fn test_deposit_and_pause_successful() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Setup token approval for deposit let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; @@ -1695,7 +1743,9 @@ fn test_deposit_and_pause_zero_amount() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); // Try to deposit_and_pause with zero amount - should panic payment_stream.deposit_and_pause(stream_id, deposit_amount); @@ -1717,7 +1767,9 @@ fn test_deposit_by_non_sender() { // Create stream start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); // Setup token approval for deposit by non-sender @@ -1738,7 +1790,7 @@ fn test_deposit_by_non_sender() { #[test] fn test_aggregate_balance_on_stream_creation() { - let (token_address, sender, payment_stream) = setup(); + let (token_address, sender, payment_stream, _) = setup(); let recipient = contract_address_const::<0x2>(); let total_amount = 1000_u256; let start_time = 100_u64; @@ -1747,7 +1799,9 @@ fn test_aggregate_balance_on_stream_creation() { start_cheat_caller_address(payment_stream.contract_address, sender); payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); // Verify that the aggregated balance match the total amount of the first stream @@ -1759,7 +1813,9 @@ fn test_aggregate_balance_on_stream_creation() { start_cheat_caller_address(payment_stream.contract_address, sender); payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); // Verify that the aggregated balance match the sum of the two streams @@ -1772,7 +1828,7 @@ fn test_aggregate_balance_on_stream_creation() { #[test] fn test_aggregate_balance_on_withdraw() { - let (token_address, sender, payment_stream) = setup(); + let (token_address, sender, payment_stream, _) = setup(); let recipient = contract_address_const::<'recipient'>(); let total_amount = 10000_u256; let start_time = 100_u64; @@ -1788,7 +1844,9 @@ fn test_aggregate_balance_on_withdraw() { start_cheat_caller_address(payment_stream.contract_address, sender); let stream_id = payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); payment_stream.delegate_stream(stream_id, delegate); stop_cheat_caller_address(payment_stream.contract_address); @@ -1817,7 +1875,7 @@ fn test_aggregate_balance_on_withdraw() { #[test] fn test_recover() { - let (token_address, sender, payment_stream) = setup(); + let (token_address, sender, payment_stream, _) = setup(); let recipient = contract_address_const::<0x2>(); let total_amount = 10000_u256; let surplus = 10_u256; @@ -1835,7 +1893,9 @@ fn test_recover() { start_cheat_caller_address(payment_stream.contract_address, sender); payment_stream - .create_stream(recipient, total_amount, start_time, end_time, cancelable, token_address); + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, true, + ); stop_cheat_caller_address(payment_stream.contract_address); start_cheat_caller_address(payment_stream.contract_address, protocol_owner); @@ -1855,7 +1915,7 @@ fn test_recover() { #[test] #[should_panic] fn test_recover_when_caller_not_admin() { - let (token_address, _, payment_stream) = setup(); + let (token_address, _, payment_stream, _) = setup(); let recipient = contract_address_const::<0x2>(); start_cheat_caller_address(payment_stream.contract_address, recipient); @@ -1866,7 +1926,7 @@ fn test_recover_when_caller_not_admin() { #[test] #[should_panic] fn test_recover_when_nothing_to_recover() { - let (token_address, _, payment_stream) = setup(); + let (token_address, _, payment_stream, _) = setup(); let protocol_owner: ContractAddress = contract_address_const::<'protocol_owner'>(); let recipient = contract_address_const::<0x2>(); @@ -2087,3 +2147,169 @@ fn test_restart_and_deposit_insufficient_allowance() { payment_stream.restart_and_deposit(stream_id, new_rate, additional_amount); stop_cheat_caller_address(payment_stream.contract_address); } + +#[test] +fn test_status_of_active() { + let (token_address, _, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 1000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let stream_id = payment_stream + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + assert_eq!(payment_stream.status_of(stream_id), StreamStatus::Active); +} + +#[test] +fn test_status_of_paused() { + let (token_address, _, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 1000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let stream_id = payment_stream + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + payment_stream.pause(stream_id); + assert_eq!(payment_stream.status_of(stream_id), StreamStatus::Paused); +} + +#[test] +fn test_status_of_voided() { + let (token_address, _, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 1000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let stream_id = payment_stream + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + payment_stream.void(stream_id); + assert_eq!(payment_stream.status_of(stream_id), StreamStatus::Voided); +} + +#[test] +fn test_status_of_canceled() { + let (token_address, sender, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 1000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let token = IERC20Dispatcher { contract_address: token_address }; + start_cheat_caller_address(token_address, sender); + token.approve(payment_stream.contract_address, total_amount); + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(payment_stream.contract_address, sender); + let stream_id = payment_stream + .create_stream_with_deposit( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + payment_stream.cancel(stream_id); + assert_eq!(payment_stream.status_of(stream_id), StreamStatus::Canceled); +} + +#[test] +fn test_ongoing_debt_scaled_of() { + let (token_address, _, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 1000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let stream_id = payment_stream + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + start_cheat_block_timestamp(payment_stream.contract_address, start_time); + assert_eq!(payment_stream.ongoing_debt_scaled_of(stream_id), 0); + start_cheat_block_timestamp( + payment_stream.contract_address, start_time + (end_time - start_time) / 2, + ); + assert_eq!(payment_stream.ongoing_debt_scaled_of(stream_id), total_amount / 2); + start_cheat_block_timestamp(payment_stream.contract_address, end_time); + assert_eq!(payment_stream.ongoing_debt_scaled_of(stream_id), total_amount); +} + +#[test] +fn test_covered_debt_of() { + let (token_address, sender, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 10000000000000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let stream_id = payment_stream + .create_stream( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + assert_eq!(payment_stream.covered_debt_of(stream_id), 0); + + let token = IERC20MixinDispatcher { contract_address: token_address }; + start_cheat_caller_address(token_address, sender); + token.approve(payment_stream.contract_address, total_amount); + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(payment_stream.contract_address, sender); + let stream_id = payment_stream + .create_stream_with_deposit( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + start_cheat_block_timestamp(payment_stream.contract_address, start_time); + assert_eq!(payment_stream.covered_debt_of(stream_id), 0); + start_cheat_block_timestamp( + payment_stream.contract_address, start_time + (end_time - start_time) / 2, + ); + assert_eq!( + payment_stream.covered_debt_of(stream_id).into(), + Helpers::descale_amount(total_amount / 2, token.decimals()), + ); + start_cheat_block_timestamp(payment_stream.contract_address, end_time); + assert_eq!( + payment_stream.covered_debt_of(stream_id).into(), + Helpers::descale_amount(total_amount, token.decimals()), + ); +} + +#[test] +fn test_uncovered_debt_of() { + let (token_address, sender, payment_stream, _) = setup(); + let recipient = contract_address_const::<0x2>(); + let total_amount = 10000000000000_u256; + let start_time = 100_u64; + let end_time = 200_u64; + let cancelable = true; + let transferable = true; + + let token = IERC20MixinDispatcher { contract_address: token_address }; + start_cheat_caller_address(token_address, sender); + token.approve(payment_stream.contract_address, total_amount); + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(payment_stream.contract_address, sender); + let stream_id = payment_stream + .create_stream_with_deposit( + recipient, total_amount, start_time, end_time, cancelable, token_address, transferable, + ); + assert_eq!(payment_stream.uncovered_debt_of(stream_id), 0); +}