diff --git a/contracts/src/tokens/interface.cairo b/contracts/src/tokens/interface.cairo index 0b9ab808..0c693924 100644 --- a/contracts/src/tokens/interface.cairo +++ b/contracts/src/tokens/interface.cairo @@ -35,6 +35,12 @@ trait IUnruggableMemecoin { // ************************************ // * Additional functions // ************************************ + + /// Checks whether token has launched + /// + /// # Returns + /// bool: whether token has launched + fn launched(self: @TState) -> bool; fn launch_memecoin(ref self: TState); } @@ -61,5 +67,10 @@ trait IUnruggableMemecoinSnake { #[starknet::interface] trait IUnruggableAdditional { + /// Checks whether token has launched + /// + /// # Returns + /// bool: whether token has launched + fn launched(self: @TState) -> bool; fn launch_memecoin(ref self: TState); } diff --git a/contracts/src/tokens/memecoin.cairo b/contracts/src/tokens/memecoin.cairo index eb6a881e..87bb27f0 100644 --- a/contracts/src/tokens/memecoin.cairo +++ b/contracts/src/tokens/memecoin.cairo @@ -40,6 +40,8 @@ mod UnruggableMemecoin { #[storage] struct Storage { marker_v_0: (), + launched: bool, + pre_launch_holders_count: u8, // Components. #[substorage(v0)] ownable: OwnableComponent::Storage, @@ -56,6 +58,10 @@ mod UnruggableMemecoin { ERC20Event: ERC20Component::Event } + mod Errors { + const MAX_HOLDERS_REACHED: felt252 = 'memecoin: max holders reached'; + } + /// Constructor called once when the contract is deployed. /// # Arguments @@ -80,7 +86,7 @@ mod UnruggableMemecoin { self.ownable.initializer(owner); // Mint initial supply to the initial recipient. - self.erc20._mint(initial_recipient, initial_supply); + self._mint(initial_recipient, initial_supply); } // @@ -91,11 +97,18 @@ mod UnruggableMemecoin { // ************************************ // * UnruggableMemecoin functions // ************************************ + + fn launched(self: @ContractState) -> bool { + self.launched.read() + } + fn launch_memecoin(ref self: ContractState) { // Checks: Only the owner can launch the memecoin. self.ownable.assert_only_owner(); - // Effects. + // Effects. + // Launch the coin + self.launched.write(true); // Interactions. } } @@ -171,12 +184,60 @@ mod UnruggableMemecoin { // #[generate_trait] impl UnruggableMemecoinInternalImpl of UnruggableMemecoinInternalTrait { + /// Internal function to enforce pre launch holder limit + /// + /// Note that when transfers are done, between addresses that already + /// hold tokens, we do not increment the number of holders. it only + /// gets incremented when the recipient that hold no tokens + /// + /// # Arguments + /// * `recipient` - The recipient of the tokens being transferred. + #[inline(always)] + fn _enforce_holders_limit(ref self: ContractState, recipient: ContractAddress) { + // enforce max number of holders before launch + + if !self.launched.read() && self.balance_of(recipient) == 0 { + let current_holders_count = self.pre_launch_holders_count.read(); + assert( + current_holders_count < MAX_HOLDERS_BEFORE_LAUNCH, Errors::MAX_HOLDERS_REACHED + ); + + self.pre_launch_holders_count.write(current_holders_count + 1); + } + } + + + /// Internal function to mint tokens + /// + /// Before minting, a check is done to ensure that + /// only `MAX_HOLDERS_BEFORE_LAUNCH` addresses can hold + /// tokens if token hasn't launched + /// + /// # Arguments + /// * `recipient` - The recipient of the tokens. + /// * `amount` - The amount of tokens to be minted. + fn _mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { + self._enforce_holders_limit(recipient); + self.erc20._mint(recipient, amount); + } + + /// Internal function to transfer tokens + /// + /// Before transferring, a check is done to ensure that + /// only `MAX_HOLDERS_BEFORE_LAUNCH` addresses can hold + /// tokens if token hasn't launched + /// + /// # Arguments + /// * `sender` - The sender or owner of the tokens. + /// * `recipient` - The recipient of the tokens. + /// * `amount` - The amount of tokens to be transferred. fn _transfer( ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256 ) { + self._enforce_holders_limit(recipient); self.erc20._transfer(sender, recipient, amount); } } diff --git a/contracts/tests/test_unruggable_memecoin.cairo b/contracts/tests/test_unruggable_memecoin.cairo index 78051e98..96bf1651 100644 --- a/contracts/tests/test_unruggable_memecoin.cairo +++ b/contracts/tests/test_unruggable_memecoin.cairo @@ -312,6 +312,8 @@ mod memecoin_entrypoints { start_prank(CheatTarget::One(memecoin.contract_address), owner); memecoin.launch_memecoin(); + + assert(memecoin.launched(), 'Coin not launched'); //TODO } @@ -330,3 +332,174 @@ mod memecoin_entrypoints { memecoin.launch_memecoin(); } } + + +mod memecoin_internals { + use UnruggableMemecoin::{ + UnruggableMemecoinInternalImpl, SnakeEntrypoints, UnruggableEntrypoints, + MAX_HOLDERS_BEFORE_LAUNCH + }; + use core::option::OptionTrait; + use snforge_std::{start_prank, CheatTarget}; + use starknet::{ContractAddress, contract_address_const}; + use unruggable::tokens::memecoin::UnruggableMemecoin; + + + #[test] + fn test__transfer() { + let owner = contract_address_const::<42>(); + let recipient = contract_address_const::<43>(); + let initial_supply = 1000.into(); + + // call contract constructor + let mut contract_state = UnruggableMemecoin::contract_state_for_testing(); + UnruggableMemecoin::constructor( + ref contract_state, owner, owner, 'UnruggableMemecoin', 'UM', initial_supply + ); + + // Transfer 100 tokens to the recipient + UnruggableMemecoinInternalImpl::_transfer(ref contract_state, owner, recipient, 100); + + // Check balance. Should be equal to initial supply - 100. + let owner_balance = SnakeEntrypoints::balance_of(@contract_state, owner); + assert(owner_balance == (initial_supply - 100), 'Invalid balance owner'); + + // Check recipient balance. Should be equal to 100. + let recipient_balance = SnakeEntrypoints::balance_of(@contract_state, recipient); + assert(recipient_balance == 100.into(), 'Invalid balance recipient'); + } + + + #[test] + fn test__transfer_recipients_equal_holder_cap() { + let owner = contract_address_const::<42>(); + let initial_supply = 1000.into(); + + // call contract constructor + let mut contract_state = UnruggableMemecoin::contract_state_for_testing(); + UnruggableMemecoin::constructor( + ref contract_state, owner, owner, 'UnruggableMemecoin', 'UM', initial_supply + ); + + // index starts from 1 because owner has initial supply + let mut index = 1; + loop { + if index == MAX_HOLDERS_BEFORE_LAUNCH { + break; + } + + // Transfer 1 token to the unique recipient + let unique_recipient: ContractAddress = (index.into() + 9999).try_into().unwrap(); + UnruggableMemecoinInternalImpl::_transfer( + ref contract_state, owner, unique_recipient, 1 + ); + + // Check recipient balance. Should be equal to 1. + let recipient_balance = SnakeEntrypoints::balance_of(@contract_state, unique_recipient); + assert(recipient_balance == 1.into(), 'Invalid balance recipient'); + + index += 1; + }; + } + + + #[test] + fn test__transfer_existing_holders() { + /// pre launch holder number should not change when + /// transfer is done to recipient(s) who already have tokens + + /// to test this, we are going to continously self transfer tokens + /// and ensure that we can transfer more than `MAX_HOLDERS_BEFORE_LAUNCH` times + + let owner = contract_address_const::<42>(); + let initial_supply = 1000.into(); + + // call contract constructor + let mut contract_state = UnruggableMemecoin::contract_state_for_testing(); + UnruggableMemecoin::constructor( + ref contract_state, owner, owner, 'UnruggableMemecoin', 'UM', initial_supply + ); + + // index starts from 1 because owner has initial supply + let mut index = 1; + loop { + if index == MAX_HOLDERS_BEFORE_LAUNCH + 1 { + break; + } + + // Self transfer tokens + + UnruggableMemecoinInternalImpl::_transfer( + ref contract_state, owner, owner, initial_supply + ); + + index += 1; + }; + } + + + #[test] + #[should_panic(expected: ('memecoin: max holders reached',))] + fn test__transfer_above_holder_cap() { + let owner = contract_address_const::<42>(); + let initial_supply = 1000.into(); + + // call contract constructor + let mut contract_state = UnruggableMemecoin::contract_state_for_testing(); + UnruggableMemecoin::constructor( + ref contract_state, owner, owner, 'UnruggableMemecoin', 'UM', initial_supply + ); + + // index starts from 1 because owner has initial supply + let mut index = 1; + loop { + if index == MAX_HOLDERS_BEFORE_LAUNCH + 1 { + break; + } + + // Transfer 1 token to the unique recipient + let unique_recipient: ContractAddress = (index.into() + 9999).try_into().unwrap(); + UnruggableMemecoinInternalImpl::_transfer( + ref contract_state, owner, unique_recipient, 1 + ); + + index += 1; + }; + } + + + #[test] + fn test__transfer_no_holder_cap_after_launch() { + let owner = contract_address_const::<42>(); + let initial_supply = 1000.into(); + + // call contract constructor + let mut contract_state = UnruggableMemecoin::contract_state_for_testing(); + UnruggableMemecoin::constructor( + ref contract_state, owner, owner, 'UnruggableMemecoin', 'UM', initial_supply + ); + + // set owner as caller to bypass owner restrictions + start_prank(CheatTarget::All, owner); + + // launch memecoin + UnruggableEntrypoints::launch_memecoin(ref contract_state); + + // index starts from 1 because owner has initial supply + let mut index = 1; + loop { + if index == MAX_HOLDERS_BEFORE_LAUNCH + 1 { + break; + } + + // Transfer 1 token to the unique recipient + let unique_recipient: ContractAddress = (index.into() + 9999).try_into().unwrap(); + UnruggableMemecoinInternalImpl::_transfer( + ref contract_state, owner, unique_recipient, 1 + ); + + index += 1; + }; + } +} +