diff --git a/src/contract.cairo b/src/contract.cairo index 4f203d6d..7ff078ab 100644 --- a/src/contract.cairo +++ b/src/contract.cairo @@ -34,6 +34,7 @@ mod Governance { use konoha::proposals::proposals as proposals_component; use konoha::staking::staking as staking_component; use konoha::staking::{IStakingDispatcher, IStakingDispatcherTrait}; + use konoha::streaming::streaming as streaming_component; use konoha::types::BlockNumber; use konoha::types::ContractType; use konoha::types::PropDetails; @@ -53,6 +54,8 @@ mod Governance { component!(path: upgrades_component, storage: upgrades, event: UpgradesEvent); component!(path: discussion_component, storage: discussions, event: DiscussionEvent); component!(path: staking_component, storage: staking, event: StakingEvent); + component!(path: streaming_component, storage: streaming, event: StreamingEvent); + #[abi(embed_v0)] impl Airdrop = airdrop_component::AirdropImpl; @@ -71,6 +74,9 @@ mod Governance { #[abi(embed_v0)] impl Staking = staking_component::StakingImpl; + #[abi(embed_v0)] + impl Streaming = streaming_component::StreamingImpl; + #[storage] struct Storage { proposal_initializer_run: LegacyMap::, @@ -87,6 +93,8 @@ mod Governance { discussions: discussion_component::Storage, #[substorage(v0)] staking: staking_component::Storage, + #[substorage(v0)] + streaming: streaming_component::Storage, } // PROPOSALS @@ -116,6 +124,7 @@ mod Governance { UpgradesEvent: upgrades_component::Event, DiscussionEvent: discussion_component::Event, StakingEvent: staking_component::Event, + StreamingEvent: streaming_component::Event, } #[constructor] diff --git a/src/lib.cairo b/src/lib.cairo index ae3f22b8..75b1cd3d 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -5,6 +5,7 @@ mod discussion; mod merkle_tree; mod proposals; mod staking; +mod streaming; mod token; mod traits; mod treasury; diff --git a/src/streaming.cairo b/src/streaming.cairo new file mode 100644 index 00000000..a22a965f --- /dev/null +++ b/src/streaming.cairo @@ -0,0 +1,167 @@ +use starknet::ContractAddress; + +#[starknet::interface] +trait IStreaming { + fn add_new_stream( + ref self: TContractState, + recipient: ContractAddress, + start_time: u64, + end_time: u64, + total_amount: u128, + ); + + fn claim_stream( + ref self: TContractState, recipient: ContractAddress, start_time: u64, end_time: u64, + ); + + fn cancel_stream( + ref self: TContractState, recipient: ContractAddress, start_time: u64, end_time: u64, + ); + + fn get_stream_info( + ref self: TContractState, recipient: ContractAddress, start_time: u64, end_time: u64, + ) -> (u128, u128); +} + +#[starknet::component] +mod streaming { + use konoha::contract::Governance; + use konoha::contract::{IGovernanceDispatcher, IGovernanceDispatcherTrait}; + use konoha::traits::{IGovernanceTokenDispatcher, IGovernanceTokenDispatcherTrait}; + use starknet::ContractAddress; + use starknet::{get_block_timestamp, get_caller_address, get_contract_address}; + + #[storage] + struct Storage { + streams: LegacyMap::< + (ContractAddress, u64, u64), (u128, u128) + > // (already_claimed, total_amount) + } + + #[derive(starknet::Event, Drop, Serde)] + #[event] + enum Event { + StreamCreated: StreamCreated, + StreamClaimed: StreamClaimed, + StreamCanceled: StreamCanceled + } + + #[derive(starknet::Event, Drop, Serde)] + struct StreamCreated { + recipient: ContractAddress, + start_time: u64, + end_time: u64, + total_amount: u128, + } + + #[derive(starknet::Event, Drop, Serde)] + struct StreamClaimed { + recipient: ContractAddress, + start_time: u64, + end_time: u64, + total_amount: u128, + } + + #[derive(starknet::Event, Drop, Serde)] + struct StreamCanceled { + recipient: ContractAddress, + start_time: u64, + end_time: u64, + reclaimed_amount: u256, + } + + #[embeddable_as(StreamingImpl)] + impl Streaming< + TContractState, +HasComponent + > of super::IStreaming> { + fn add_new_stream( + ref self: ComponentState, + recipient: ContractAddress, + start_time: u64, + end_time: u64, + total_amount: u128, + ) { + let key = (recipient, start_time, end_time); + + assert(get_caller_address() == get_contract_address(), 'not self-call'); + assert(start_time < end_time, 'starts first'); + + let mut claimable_amount = 0; + self.streams.write(key, (claimable_amount, total_amount)); + + self.emit(StreamCreated { recipient, start_time, end_time, total_amount, }); + } + + fn claim_stream( + ref self: ComponentState, + recipient: ContractAddress, + start_time: u64, + end_time: u64, + ) { + let current_time = get_block_timestamp(); + + let key = (recipient, start_time, end_time); + let (already_claimed, total_amount): (u128, u128) = self.streams.read(key); + assert(current_time > start_time, 'stream has not started'); + + let elapsed_time = if current_time > end_time { + end_time - start_time + } else { + current_time - start_time + }; + let stream_duration = end_time - start_time; + + let currently_claimable = (total_amount * elapsed_time.into() / stream_duration.into()); + let amount_to_claim = currently_claimable - already_claimed; + + assert(amount_to_claim > 0, 'nothing to claim'); + + // Update the storage with the new claimed amount + self.streams.write(key, (currently_claimable, total_amount)); + + let self_dsp = IGovernanceDispatcher { contract_address: get_contract_address() }; + IGovernanceTokenDispatcher { contract_address: self_dsp.get_governance_token_address() } + .mint(recipient, amount_to_claim.into()); + + self.emit(StreamClaimed { recipient, start_time, end_time, total_amount, }); + } + + fn cancel_stream( + ref self: ComponentState, + recipient: ContractAddress, + start_time: u64, + end_time: u64, + ) { + let key: (ContractAddress, u64, u64) = (recipient, start_time, end_time); + + // Read from the streams LegacyMap + let (already_claimed, total_amount): (u128, u128) = self.streams.read(key); + let to_distribute: u256 = total_amount.into() - already_claimed.into(); + + // Cancel stream + self.streams.write(key, (0, 0)); + + let self_dsp = IGovernanceDispatcher { contract_address: get_contract_address() }; + IGovernanceTokenDispatcher { contract_address: self_dsp.get_governance_token_address() } + .mint(get_caller_address(), to_distribute.into()); + + self + .emit( + StreamCanceled { + recipient, start_time, end_time, reclaimed_amount: to_distribute, + } + ); + } + + fn get_stream_info( + ref self: ComponentState, + recipient: ContractAddress, + start_time: u64, + end_time: u64, + ) -> (u128, u128) { + let key: (ContractAddress, u64, u64) = (recipient, start_time, end_time); + let (currently_claimable, total_amount): (u128, u128) = self.streams.read(key); + (currently_claimable, total_amount) + } + } +} diff --git a/tests/lib.cairo b/tests/lib.cairo index 222fa353..a7df965e 100644 --- a/tests/lib.cairo +++ b/tests/lib.cairo @@ -4,5 +4,7 @@ mod proposals_tests; mod setup; mod staking_tests; mod test_storage_pack; +mod test_streaming; mod test_treasury; mod upgrades_tests; +mod vesting; diff --git a/tests/test_streaming.cairo b/tests/test_streaming.cairo new file mode 100644 index 00000000..7b5b5df8 --- /dev/null +++ b/tests/test_streaming.cairo @@ -0,0 +1,173 @@ +use array::ArrayTrait; +use core::option::OptionTrait; +use core::result::ResultTrait; +use core::traits::TryInto; +use debug::PrintTrait; + +use konoha::contract::Governance; +use konoha::contract::{IGovernanceDispatcher, IGovernanceDispatcherTrait}; +use konoha::streaming::{IStreamingDispatcher, IStreamingDispatcherTrait, IStreaming}; +use konoha::traits::{IGovernanceTokenDispatcher, IGovernanceTokenDispatcherTrait}; +use konoha::vesting::{IVestingDispatcher, IVestingDispatcherTrait, IVesting}; + +use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::{ + BlockId, declare, ContractClassTrait, ContractClass, start_prank, start_warp, CheatTarget, + prank, CheatSpan +}; +use starknet::{get_block_timestamp, get_caller_address, get_contract_address, ContractAddress}; + +use super::setup::{deploy_governance_and_both_tokens}; + +fn start_stream(gov: ContractAddress) { + prank(CheatTarget::One(gov), gov, CheatSpan::TargetCalls(4)); + let streaming = IStreamingDispatcher { contract_address: gov }; + streaming.add_new_stream(0x2.try_into().unwrap(), 100, 200, 100000); +} + +//passing! +#[test] +fn test_add_new_stream() { + let (gov, _, _) = deploy_governance_and_both_tokens(); + start_stream(gov.contract_address); + let streaming = IStreamingDispatcher { contract_address: gov.contract_address }; + + let recipient = 0x2.try_into().unwrap(); + let start_time: u64 = 100; + let end_time: u64 = 200; + let total_amount: u128 = 100000; + + streaming.add_new_stream(recipient, start_time, end_time, total_amount); + //let key = (get_caller_address(), recipient, end_time, start_time); + + let (claimed_amount, stored_total_amount) = streaming + .get_stream_info(recipient, start_time, end_time,); + + assert_eq!(recipient, 0x2.try_into().unwrap(), "Incorrect streamer addr"); + assert_eq!(start_time, 100, "Incorrect start time"); + assert_eq!(end_time, 200, "Incorrect end time"); + assert_eq!(claimed_amount, 0, "Incorrect claimed amount after stream creation"); + assert_eq!(stored_total_amount, 100000, "Incorrect total amount stored"); +} + +//passing! +#[test] +#[should_panic(expected: ('starts first',))] +fn test_valid_stream_time() { + let (gov, _, _) = deploy_governance_and_both_tokens(); + start_stream(gov.contract_address); + let streaming = IStreamingDispatcher { contract_address: gov.contract_address }; + + let recipient = 0x2.try_into().unwrap(); + let start_time: u64 = 200; + let end_time: u64 = 100; + let total_amount: u128 = 100000; + + streaming.add_new_stream(recipient, start_time, end_time, total_amount); +} + +//passing! +#[test] +#[should_panic(expected: ('nothing to claim',))] +fn test_claimed_amount() { + let (gov, _, _) = deploy_governance_and_both_tokens(); + start_stream(gov.contract_address); + let streaming = IStreamingDispatcher { contract_address: gov.contract_address }; + + let recipient = 0x2.try_into().unwrap(); + let start_time: u64 = 100; + let end_time: u64 = 200; + let total_amount: u128 = 0; + streaming.add_new_stream(recipient, start_time, end_time, total_amount); + + start_warp(CheatTarget::One(gov.contract_address), 150); + //shouldn't have anything to claim + streaming.claim_stream(recipient, start_time, end_time); +} + +//passing! +#[test] +#[should_panic(expected: ('stream has not started',))] +fn test_stream_started() { + let (gov, _, _) = deploy_governance_and_both_tokens(); + start_stream(gov.contract_address); + let streaming = IStreamingDispatcher { contract_address: gov.contract_address }; + + let recipient = 0x2.try_into().unwrap(); + let start_time: u64 = 100; + let end_time: u64 = 200; + let total_amount: u128 = 100000; + streaming.add_new_stream(recipient, start_time, end_time, total_amount); + start_warp(CheatTarget::One(gov.contract_address), 50); // before of stream + + streaming.claim_stream(recipient, start_time, end_time); +} + +#[test] +fn test_claim_stream() { + let (gov, _, _) = deploy_governance_and_both_tokens(); + start_stream(gov.contract_address); + let streaming = IStreamingDispatcher { contract_address: gov.contract_address }; + + let recipient = 0x2.try_into().unwrap(); + let start_time: u64 = 100; + let end_time: u64 = 200; + let total_amount: u128 = 100000; + + streaming.add_new_stream(recipient, start_time, end_time, total_amount); + let (claimable_amount, total_amount) = streaming + .get_stream_info(recipient, start_time, end_time,); + start_warp(CheatTarget::One(gov.contract_address), 150); + + streaming.claim_stream(recipient, start_time, end_time); + + let expected_claimed_amount = (100000 * 50 / 100); //should be 50% since middle of stream + assert_eq!(total_amount, 100000, "Incorrect total amount after claiming the stream"); + assert_eq!(claimable_amount, 0, "Incorrect claimed amount after claiming the stream"); + + let self_dsp = IGovernanceDispatcher { contract_address: gov.contract_address }; + let token_address = self_dsp.get_governance_token_address(); + let erc20 = IERC20Dispatcher { contract_address: token_address }; + + let balance = erc20.balance_of(recipient); + + assert_eq!( + balance, expected_claimed_amount, "Balance should match the expected claimed amount" + ); +} + +#[test] +fn test_cancel_stream() { + let (gov, _, _) = deploy_governance_and_both_tokens(); + start_stream(gov.contract_address); + let streaming = IStreamingDispatcher { contract_address: gov.contract_address }; + + let recipient = 0x2.try_into().unwrap(); + let start_time: u64 = 100; + let end_time: u64 = 200; + let total_amount: u128 = 100000; + + streaming.add_new_stream(recipient, start_time, end_time, total_amount); + + start_warp(CheatTarget::One(gov.contract_address), 150); + + //test cancel_stream + streaming.cancel_stream(recipient, start_time, end_time); + + let (claimed_amount, stored_total_amount) = streaming + .get_stream_info(recipient, start_time, end_time); + + assert_eq!(claimed_amount, 0, "Claimed amount should be 0 after canceling the stream"); + assert_eq!(stored_total_amount, 0, "Total amount should be 0 after canceling the stream"); + + let unclaimed_amount: u256 = total_amount.into() - claimed_amount.into(); //100000 + let self_dsp = IGovernanceDispatcher { contract_address: gov.contract_address }; + let token_address = self_dsp.get_governance_token_address(); + let erc20 = IERC20Dispatcher { contract_address: token_address }; + + // Check the balance of the streamer (caller address) with ERC_20, I couldnt use balance_of + let balance = erc20.balance_of(get_caller_address()); + + assert_eq!(unclaimed_amount.into(), 100000, "Unclaimed amount should be reclaimed correctly"); + assert_eq!(balance, 0, "balance"); +} diff --git a/tests/vesting.cairo b/tests/vesting.cairo index 8c8f19b5..b17bfc1b 100644 --- a/tests/vesting.cairo +++ b/tests/vesting.cairo @@ -7,54 +7,47 @@ use debug::PrintTrait; use konoha::vesting::{IVestingDispatcher, IVestingDispatcherTrait, IVesting}; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; use snforge_std::{ - BlockId, declare, ContractClassTrait, ContractClass, start_prank, start_warp, CheatTarget + BlockId, declare, ContractClassTrait, ContractClass, start_prank, start_warp, CheatTarget, + prank, CheatSpan }; -use starknet::ContractAddress; - -// returns gov addr, token addr -fn test_setup() -> (ContractAddress, ContractAddress) { - let new_gov_contract: ContractClass = declare("Governance") - .expect('unable to declare Governance'); - let new_token_contract: ContractClass = declare("MyToken").expect('unable to declare MyToken'); - let new_gov_addr: ContractAddress = - 0x001405ab78ab6ec90fba09e6116f373cda53b0ba557789a4578d8c1ec374ba0f - .try_into() - .unwrap(); - let mut token_constructor = ArrayTrait::new(); - token_constructor.append(new_gov_addr.into()); // Owner - let (token_address, _) = new_token_contract - .deploy(@token_constructor) - .expect('unable to deploy token'); - let mut gov_constructor: Array = ArrayTrait::new(); - gov_constructor.append(token_address.into()); - let (gov_address, _) = new_gov_contract - .deploy_at(@gov_constructor, new_gov_addr) - .expect('unable to deploy gov'); - - (gov_address, token_address) +use starknet::{ContractAddress, get_caller_address}; +use super::setup::{deploy_governance_and_both_tokens}; + +fn test_setup(gov: ContractAddress) { + let grantee: ContractAddress = 0x1.try_into().unwrap(); + prank(CheatTarget::One(gov), gov, CheatSpan::TargetCalls(4)); + let gov_vesting = IVestingDispatcher { contract_address: gov }; + gov_vesting.add_linear_vesting_schedule(10, 10, 10, 1000000, grantee); } #[test] #[should_panic(expected: ('not self-call',))] fn test_unauthorized_add_vesting_schedule() { - let (gov_address, _) = test_setup(); + let (gov, _, _) = deploy_governance_and_both_tokens(); + test_setup(gov.contract_address); - let gov_vesting = IVestingDispatcher { contract_address: gov_address }; + let gov_vesting = IVestingDispatcher { contract_address: gov.contract_address }; + + let caller = get_caller_address(); start_warp(CheatTarget::All, 1); + start_prank(CheatTarget::One(gov.contract_address), caller); - gov_vesting.add_linear_vesting_schedule(10, 10, 10, 1000000, 0x1.try_into().unwrap()); + let grantee: ContractAddress = 0x1.try_into().unwrap(); + + gov_vesting.add_linear_vesting_schedule(10, 10, 10, 1000000, grantee); } #[test] #[should_panic(expected: ('not yet eligible',))] fn test_unauthorized_vest_early() { - let (gov_address, _) = test_setup(); + let (gov, _, _) = deploy_governance_and_both_tokens(); + test_setup(gov.contract_address); - let gov_vesting = IVestingDispatcher { contract_address: gov_address }; + let gov_vesting = IVestingDispatcher { contract_address: gov.contract_address }; start_warp(CheatTarget::All, 1); - start_prank(CheatTarget::One(gov_address), gov_address); + start_prank(CheatTarget::One(gov.contract_address), gov.contract_address); let grantee: ContractAddress = 0x1.try_into().unwrap(); @@ -66,12 +59,13 @@ fn test_unauthorized_vest_early() { #[test] #[should_panic(expected: ('nothing to vest',))] fn test_vest_twice() { - let (gov_address, _) = test_setup(); + let (gov, _, _) = deploy_governance_and_both_tokens(); + test_setup(gov.contract_address); - let gov_vesting = IVestingDispatcher { contract_address: gov_address }; + let gov_vesting = IVestingDispatcher { contract_address: gov.contract_address }; start_warp(CheatTarget::All, 1); - start_prank(CheatTarget::One(gov_address), gov_address); + start_prank(CheatTarget::One(gov.contract_address), gov.contract_address); let grantee: ContractAddress = 0x1.try_into().unwrap(); @@ -85,13 +79,11 @@ fn test_vest_twice() { #[test] fn test_add_simple_vesting_schedule() { - let (gov_address, token_address) = test_setup(); + let (gov, token_address, _) = deploy_governance_and_both_tokens(); + test_setup(gov.contract_address); - let gov_vesting = IVestingDispatcher { contract_address: gov_address }; - let tok = IERC20Dispatcher { contract_address: token_address }; - - start_warp(CheatTarget::All, 1); - start_prank(CheatTarget::One(gov_address), gov_address); + let gov_vesting = IVestingDispatcher { contract_address: gov.contract_address }; + let tok = IERC20Dispatcher { contract_address: token_address.contract_address }; let grantee: ContractAddress = 0x1.try_into().unwrap(); gov_vesting.add_linear_vesting_schedule(10, 10, 10, 1000001, grantee); @@ -102,7 +94,7 @@ fn test_add_simple_vesting_schedule() { assert(tok.balance_of(grantee) == 100000, 'vesting unsuccessful'); // grantee themselves can claim too - start_prank(CheatTarget::One(gov_address), grantee); + start_prank(CheatTarget::One(gov.contract_address), grantee); start_warp(CheatTarget::All, 21); // past second vest gov_vesting.vest(grantee, 20); assert(tok.balance_of(grantee) == 200000, 'vesting unsuccessful');