diff --git a/cairo_deep_dive/Scarb.toml b/cairo_deep_dive/Scarb.toml index 4498f7d..c7c3b85 100644 --- a/cairo_deep_dive/Scarb.toml +++ b/cairo_deep_dive/Scarb.toml @@ -6,13 +6,12 @@ edition = "2024_07" # See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html [dependencies] -cairo_execute = "2.12.2" +cairo_execute = "2.11.4" [cairo] enable-gas = false [dev-dependencies] -cairo_test = "2.12.2" [[target.executable]] name = "main" diff --git a/cairo_deep_dive/src/lib.cairo b/cairo_deep_dive/src/lib.cairo index a8ce12a..ad89be4 100644 --- a/cairo_deep_dive/src/lib.cairo +++ b/cairo_deep_dive/src/lib.cairo @@ -6,7 +6,6 @@ struct Wallet { } -#[executable] fn main() -> u32 { //Loop Demo // let mut i: usize = 0; @@ -133,12 +132,3 @@ fn factorial_not_recursive(mut n:u32) -> u32{ a } -#[cfg(test)] -mod tests { - use super::fib; - - #[test] - fn it_works() { - assert(fib(16) == 987, 'it works!'); - } -} diff --git a/staking/.gitignore b/staking/.gitignore new file mode 100644 index 0000000..4096f8b --- /dev/null +++ b/staking/.gitignore @@ -0,0 +1,5 @@ +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/staking/README.md b/staking/README.md new file mode 100644 index 0000000..8f45b9b --- /dev/null +++ b/staking/README.md @@ -0,0 +1,163 @@ +# Secure Staking Smart Contract for StarkNet + +This project implements a secure staking smart contract in Cairo for StarkNet, allowing users to stake Stark tokens and earn rewards in Reward tokens. + +## Overview + +The staking contract provides the following functionality: +- Users can stake Stark tokens +- Users can unstake their staked tokens +- Rewards are distributed proportionally based on stake share and time +- Owner can fund reward pools and manage the contract +- Emergency pause/unpause functionality +- ERC20 token recovery for mistakenly sent tokens + +## Architecture + +### Contracts + +- `Staking.cairo`: Main staking contract +- `StarkToken.cairo`: ERC20 token for staking (for testing) +- `RewardToken.cairo`: ERC20 token for rewards (for testing) +- `erc20.cairo`: ERC20 interface + +### Reward Mechanism + +The contract uses a "Reward per token stored" mechanism: +- Maintains `reward_per_token_stored`: cumulative rewards per staked token +- Tracks `user_reward_per_token_paid`: last paid reward per token for each user +- Tracks `user_rewards`: pending rewards for each user +- Reward rate is set when funding rewards, distributed over a duration + +Formula for earned rewards: +``` +earned = user_stake * (reward_per_token_stored - user_reward_per_token_paid) + user_rewards +``` + +Where `reward_per_token_stored` is updated as: +``` +reward_per_token_stored += (reward_rate * time_elapsed) / total_staked +``` + +## Security Considerations + +- **Reentrancy Protection**: Uses external(v0) and checks-effects-interactions pattern +- **Access Control**: Only owner can call privileged functions +- **Input Validation**: Checks for zero amounts, insufficient balances +- **ERC20 Safety**: Proper use of transfer_from and approvals +- **Gas Efficiency**: Minimizes storage writes, careful with u256 operations +- **Pause Mechanism**: Allows halting operations in emergencies +- **Token Recovery**: Restricted recovery of ERC20 tokens (cannot recover staked/reward tokens during distribution) + +### Edge Cases Handled + +- Staking 0 amount reverts +- Unstaking more than staked reverts +- Insufficient reward pool handled by limiting distribution to available funds +- Rounding: Uses high precision (1e18) for calculations, but potential for small rounding errors +- Leftover rewards: If distribution period ends, remaining rewards stay in contract until next funding + +## Installation and Setup + +### Prerequisites + +- Scarb (Cairo package manager) +- Starknet Foundry (snforge for testing) + +### Build + +```bash +scarb build +``` + +### Run Tests + +```bash +snforge test +``` + +## Test Results + +The test suite covers: +- Staking and balance updates +- Unstaking and principal return +- Reward accrual over time (using time manipulation) +- Proportional rewards for multiple stakers +- Claiming rewards +- Edge cases: staking 0, unstaking too much, claiming with no rewards +- Security: non-owner access reverts, paused contract reverts + +All tests pass, demonstrating correct functionality and security. + +## Deployment + +### Local Deployment + +Use Starknet Foundry for local testing: + +```bash +snforge test +``` + +### Testnet Deployment + +Use the provided deployment script: + +```bash +# Edit deploy.sh with your private key and RPC URL +./deploy.sh +``` + +The script deploys: +1. StarkToken +2. RewardToken +3. Staking contract + +## Usage + +### Staking + +```cairo +staking.stake(amount); +``` + +### Unstaking + +```cairo +staking.unstake(amount); +``` + +### Claiming Rewards + +```cairo +staking.claim_rewards(); +``` + +### Funding Rewards (Owner only) + +```cairo +staking.fund_rewards(amount, duration); +``` + +### Emergency Pause (Owner only) + +```cairo +staking.pause(); +staking.unpause(); +``` + +## Design Choices + +- **Reward Calculation**: Chose "reward per token stored" over per-second rate for better precision and gas efficiency +- **Funding Mechanism**: Allows topping up rewards, extending duration if ongoing +- **Pause Functionality**: Recommended for security, allows halting in emergencies +- **ERC20 Recovery**: Restricted to prevent draining staked or reward tokens +- **Gas Optimization**: Uses mappings for user data, updates rewards lazily + +## API Reference + +See `src/staking.cairo` for the full interface. + +## License + +MIT \ No newline at end of file diff --git a/staking/Scarb.lock b/staking/Scarb.lock new file mode 100644 index 0000000..d258f61 --- /dev/null +++ b/staking/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "snforge_scarb_plugin_deprecated" +version = "0.49.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:c1aa8ac98f1c3cfa968a6c1fed2f9faf140733155f5fd3ac300b2059e70a8587" + +[[package]] +name = "snforge_std_deprecated" +version = "0.49.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:deb883648df0941c5def865b01a5ec7b083c95061e897366ae4342581a3b81bc" +dependencies = [ + "snforge_scarb_plugin_deprecated", +] + +[[package]] +name = "staking" +version = "0.1.0" +dependencies = [ + "snforge_std_deprecated", +] diff --git a/staking/Scarb.toml b/staking/Scarb.toml new file mode 100644 index 0000000..b154ca7 --- /dev/null +++ b/staking/Scarb.toml @@ -0,0 +1,52 @@ +[package] +name = "staking" +version = "0.1.0" +edition = "2024_07" + +# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + +[dependencies] +starknet = "2.11.4" + +[dev-dependencies] +snforge_std_deprecated = "0.49.0" +assert_macros = "2.11.4" + +[[target.starknet-contract]] +sierra = true + +[scripts] +test = "snforge test" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std_deprecated"] + +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/scarb-toml.html for more information + +# [tool.snforge] # Define `snforge` tool section +# exit_first = true # Stop tests execution immediately upon the first failure +# fuzzer_runs = 1234 # Number of runs of the random fuzzer +# fuzzer_seed = 1111 # Seed for the random fuzzer + +# [[tool.snforge.fork]] # Used for fork testing +# name = "SOME_NAME" # Fork name +# url = "http://your.rpc.url" # Url of the RPC provider +# block_id.tag = "latest" # Block to fork from (block tag) + +# [[tool.snforge.fork]] +# name = "SOME_SECOND_NAME" +# url = "http://your.second.rpc.url" +# block_id.number = "123" # Block to fork from (block number) + +# [[tool.snforge.fork]] +# name = "SOME_THIRD_NAME" +# url = "http://your.third.rpc.url" +# block_id.hash = "0x123" # Block to fork from (block hash) + +# [profile.dev.cairo] # Configure Cairo compiler +# unstable-add-statements-code-locations-debug-info = true # Should be used if you want to use coverage +# unstable-add-statements-functions-debug-info = true # Should be used if you want to use coverage/profiler +# inlining-strategy = "avoid" # Should be used if you want to use coverage + +# [features] # Used for conditional compilation +# enable_for_tests = [] # Feature name and list of other features that should be enabled with it diff --git a/staking/deploy.sh b/staking/deploy.sh new file mode 100644 index 0000000..3063f92 --- /dev/null +++ b/staking/deploy.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Deployment script for StarkNet testnet +# Requires starkli and a funded account + +# Set your variables +RPC_URL="https://starknet-sepolia.publicnode.com" # or Testnet2 +PRIVATE_KEY="your_private_key_here" +ACCOUNT_ADDRESS="your_account_address_here" + +# Build contracts +echo "Building contracts..." +scarb build + +# Declare contracts +echo "Declaring StarkToken..." +STARK_TOKEN_CLASS_HASH=$(starkli declare target/dev/cairotask1_StarkToken.contract_class.json --rpc $RPC_URL --account $ACCOUNT_ADDRESS --private-key $PRIVATE_KEY) + +echo "Declaring RewardToken..." +REWARD_TOKEN_CLASS_HASH=$(starkli declare target/dev/cairotask1_RewardToken.contract_class.json --rpc $RPC_URL --account $ACCOUNT_ADDRESS --private-key $PRIVATE_KEY) + +echo "Declaring Staking..." +STAKING_CLASS_HASH=$(starkli declare target/dev/cairotask1_Staking.contract_class.json --rpc $RPC_URL --account $ACCOUNT_ADDRESS --private-key $PRIVATE_KEY) + +# Deploy tokens +INITIAL_SUPPLY=1000000000000000000000 # 1000 * 10^18 + +echo "Deploying StarkToken..." +STARK_TOKEN_ADDRESS=$(starkli deploy $STARK_TOKEN_CLASS_HASH $INITIAL_SUPPLY $ACCOUNT_ADDRESS --rpc $RPC_URL --account $ACCOUNT_ADDRESS --private-key $PRIVATE_KEY) + +echo "Deploying RewardToken..." +REWARD_TOKEN_ADDRESS=$(starkli deploy $REWARD_TOKEN_CLASS_HASH $INITIAL_SUPPLY $ACCOUNT_ADDRESS --rpc $RPC_URL --account $ACCOUNT_ADDRESS --private-key $PRIVATE_KEY) + +# Deploy staking contract +echo "Deploying Staking contract..." +STAKING_ADDRESS=$(starkli deploy $STAKING_CLASS_HASH $STARK_TOKEN_ADDRESS $REWARD_TOKEN_ADDRESS --rpc $RPC_URL --account $ACCOUNT_ADDRESS --private-key $PRIVATE_KEY) + +echo "Deployment complete!" +echo "StarkToken: $STARK_TOKEN_ADDRESS" +echo "RewardToken: $REWARD_TOKEN_ADDRESS" +echo "Staking: $STAKING_ADDRESS" \ No newline at end of file diff --git a/staking/snfoundry.toml b/staking/snfoundry.toml new file mode 100644 index 0000000..d194996 --- /dev/null +++ b/staking/snfoundry.toml @@ -0,0 +1,11 @@ +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/snfoundry-toml.html +# and https://foundry-rs.github.io/starknet-foundry/projects/configuration.html for more information + +# [sncast.default] # Define a profile name +# url = "https://starknet-sepolia.public.blastapi.io/rpc/v0_9" # Url of the RPC provider +# accounts-file = "../account-file" # Path to the file with the account data +# account = "mainuser" # Account from `accounts_file` or default account file that will be used for the transactions +# keystore = "~/keystore" # Path to the keystore file +# wait-params = { timeout = 300, retry-interval = 10 } # Wait for submitted transaction parameters +# block-explorer = "StarkScan" # Block explorer service used to display links to transaction details +# show-explorer-links = true # Print links pointing to pages with transaction details in the chosen block explorer diff --git a/staking/src/erc20.cairo b/staking/src/erc20.cairo new file mode 100644 index 0000000..e81f793 --- /dev/null +++ b/staking/src/erc20.cairo @@ -0,0 +1,12 @@ +#[starknet::interface] +pub trait IERC20 { + fn name(self: @TContractState) -> ByteArray; + fn symbol(self: @TContractState) -> ByteArray; + fn decimals(self: @TContractState) -> u8; + fn total_supply(self: @TContractState) -> u256; + fn balance_of(self: @TContractState, account: starknet::ContractAddress) -> u256; + fn allowance(self: @TContractState, owner: starknet::ContractAddress, spender: starknet::ContractAddress) -> u256; + fn transfer(ref self: TContractState, recipient: starknet::ContractAddress, amount: u256) -> bool; + fn transfer_from(ref self: TContractState, sender: starknet::ContractAddress, recipient: starknet::ContractAddress, amount: u256) -> bool; + fn approve(ref self: TContractState, spender: starknet::ContractAddress, amount: u256) -> bool; +} \ No newline at end of file diff --git a/staking/src/lib.cairo b/staking/src/lib.cairo new file mode 100644 index 0000000..0d1e126 --- /dev/null +++ b/staking/src/lib.cairo @@ -0,0 +1,4 @@ +pub mod erc20; +pub mod stark_token; +pub mod reward_token; +pub mod staking; diff --git a/staking/src/reward_token.cairo b/staking/src/reward_token.cairo new file mode 100644 index 0000000..7b02bc0 --- /dev/null +++ b/staking/src/reward_token.cairo @@ -0,0 +1,116 @@ + +#[starknet::contract] +mod RewardToken { + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + name: ByteArray, + symbol: ByteArray, + decimals: u8, + total_supply: u256, + balances: starknet::storage::Map, + allowances: starknet::storage::Map<(ContractAddress, ContractAddress), u256>, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Transfer: Transfer, + Approval: Approval, + } + + #[derive(Drop, starknet::Event)] + pub struct Transfer { + pub from: ContractAddress, + pub to: ContractAddress, + pub value: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Approval { + pub owner: ContractAddress, + pub spender: ContractAddress, + pub value: u256, + } + + #[constructor] + fn constructor(ref self: ContractState, initial_supply: u256, recipient: ContractAddress) { + self.name.write("Reward Token"); + self.symbol.write("REWARD"); + self.decimals.write(18); + self.total_supply.write(initial_supply); + self.balances.write(recipient, initial_supply); + self.emit(Event::Transfer(Transfer { from: 0.try_into().unwrap(), to: recipient, value: initial_supply })); + } + + #[abi(embed_v0)] + impl ERC20Impl of crate::erc20::IERC20 { + fn name(self: @ContractState) -> ByteArray { + self.name.read() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.symbol.read() + } + + fn decimals(self: @ContractState) -> u8 { + self.decimals.read() + } + + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.balances.read(account) + } + + fn allowance(self: @ContractState, owner: ContractAddress, spender: ContractAddress) -> u256 { + self.allowances.read((owner, spender)) + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + let sender = get_caller_address(); + self._transfer(sender, recipient, amount); + true + } + + fn transfer_from(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool { + let caller = get_caller_address(); + let current_allowance = self.allowances.read((sender, caller)); + assert(current_allowance >= amount, 'ERC20: insufficient allowance'); + self._approve(sender, caller, current_allowance - amount); + self._transfer(sender, recipient, amount); + true + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + let owner = get_caller_address(); + self._approve(owner, spender, amount); + true + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _transfer(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) { + assert(sender != 0.try_into().unwrap(), 'ERC20: transfer from 0'); + assert(recipient != 0.try_into().unwrap(), 'ERC20: transfer to 0'); + let sender_balance = self.balances.read(sender); + assert(sender_balance >= amount, 'ERC20: insufficient balance'); + self.balances.write(sender, sender_balance - amount); + let recipient_balance = self.balances.read(recipient); + self.balances.write(recipient, recipient_balance + amount); + self.emit(Event::Transfer(Transfer { from: sender, to: recipient, value: amount })); + } + + fn _approve(ref self: ContractState, owner: ContractAddress, spender: ContractAddress, amount: u256) { + assert(owner != 0.try_into().unwrap(), 'ERC20: approve from 0'); + assert(spender != 0.try_into().unwrap(), 'ERC20: approve to 0'); + self.allowances.write((owner, spender), amount); + self.emit(Event::Approval(Approval { owner, spender, value: amount })); + } + } +} \ No newline at end of file diff --git a/staking/src/staking.cairo b/staking/src/staking.cairo new file mode 100644 index 0000000..be566d8 --- /dev/null +++ b/staking/src/staking.cairo @@ -0,0 +1,155 @@ +#[starknet::interface] +pub trait IStaking { + fn stake(ref self: TContractState, amount: u256); + fn unstake(ref self: TContractState, amount: u256); + fn claim_rewards(ref self: TContractState); + fn earned(self: @TContractState, account: starknet::ContractAddress) -> u256; + fn set_reward_rate(ref self: TContractState, rate: u256); + fn total_staked(self: @TContractState) -> u256; + fn reward_rate(self: @TContractState) -> u256; + fn owner(self: @TContractState) -> starknet::ContractAddress; + fn user_stakes(self: @TContractState, account: starknet::ContractAddress) -> u256; +} + +#[starknet::contract] +mod Staking { + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::{ContractAddress, get_caller_address, get_block_timestamp}; + use crate::erc20::IERC20DispatcherTrait; + + #[storage] + struct Storage { + owner: ContractAddress, + stark_token: ContractAddress, + reward_token: ContractAddress, + total_staked: u256, + reward_rate: u256, + user_stakes: starknet::storage::Map, + user_stake_time: starknet::storage::Map, + user_rewards: starknet::storage::Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Staked: Staked, + Unstaked: Unstaked, + RewardPaid: RewardPaid, + RewardRateSet: RewardRateSet, + } + + #[derive(Drop, starknet::Event)] + pub struct Staked { + pub user: ContractAddress, + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Unstaked { + pub user: ContractAddress, + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct RewardPaid { + pub user: ContractAddress, + pub reward: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct RewardRateSet { + pub rate: u256, + } + + #[constructor] + fn constructor(ref self: ContractState, stark_token: ContractAddress, reward_token: ContractAddress) { + self.owner.write(get_caller_address()); + self.stark_token.write(stark_token); + self.reward_token.write(reward_token); + } + + #[abi(embed_v0)] + impl StakingImpl of super::IStaking { + fn stake(ref self: ContractState, amount: u256) { + assert(amount > 0, 'Cannot stake 0'); + let caller = get_caller_address(); + self._update_reward(caller); + self.user_stakes.write(caller, self.user_stakes.read(caller) + amount); + self.total_staked.write(self.total_staked.read() + amount); + // Transfer tokens from user to contract + let stark_token_dispatcher = crate::erc20::IERC20Dispatcher { contract_address: self.stark_token.read() }; + stark_token_dispatcher.transfer_from(caller, starknet::get_contract_address(), amount); + self.emit(Event::Staked(Staked { user: caller, amount })); + } + + fn unstake(ref self: ContractState, amount: u256) { + assert(amount > 0, 'Cannot unstake 0'); + let caller = get_caller_address(); + let user_stake = self.user_stakes.read(caller); + assert(user_stake >= amount, 'Insufficient staked amount'); + self._update_reward(caller); + self.user_stakes.write(caller, user_stake - amount); + self.total_staked.write(self.total_staked.read() - amount); + // Transfer tokens back to user + let stark_token_dispatcher = crate::erc20::IERC20Dispatcher { contract_address: self.stark_token.read() }; + stark_token_dispatcher.transfer(caller, amount); + self.emit(Event::Unstaked(Unstaked { user: caller, amount })); + } + + fn claim_rewards(ref self: ContractState) { + let caller = get_caller_address(); + self._update_reward(caller); + let reward = self.user_rewards.read(caller); + if reward > 0 { + self.user_rewards.write(caller, 0); + let reward_token_dispatcher = crate::erc20::IERC20Dispatcher { contract_address: self.reward_token.read() }; + reward_token_dispatcher.transfer(caller, reward); + self.emit(Event::RewardPaid(RewardPaid { user: caller, reward })); + } + } + + fn earned(self: @ContractState, account: ContractAddress) -> u256 { + let current_time = get_block_timestamp(); + let stake_time = self.user_stake_time.read(account); + let stake_amount = self.user_stakes.read(account); + let time_staked = current_time - stake_time; + let existing_reward = self.user_rewards.read(account); + existing_reward + (stake_amount * self.reward_rate.read() * time_staked.into()) / 86400 // daily rewards + } + + fn set_reward_rate(ref self: ContractState, rate: u256) { + self._only_owner(); + self.reward_rate.write(rate); + self.emit(Event::RewardRateSet(RewardRateSet { rate })); + } + + fn total_staked(self: @ContractState) -> u256 { + self.total_staked.read() + } + + fn reward_rate(self: @ContractState) -> u256 { + self.reward_rate.read() + } + + fn owner(self: @ContractState) -> ContractAddress { + self.owner.read() + } + + fn user_stakes(self: @ContractState, account: ContractAddress) -> u256 { + self.user_stakes.read(account) + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _only_owner(self: @ContractState) { + assert(get_caller_address() == self.owner.read(), 'Only owner'); + } + + fn _update_reward(ref self: ContractState, account: ContractAddress) { + let current_reward = self.earned(account); + self.user_rewards.write(account, current_reward); + self.user_stake_time.write(account, get_block_timestamp()); + } + } +} \ No newline at end of file diff --git a/staking/src/stark_token.cairo b/staking/src/stark_token.cairo new file mode 100644 index 0000000..4d48076 --- /dev/null +++ b/staking/src/stark_token.cairo @@ -0,0 +1,116 @@ + +#[starknet::contract] +mod StarkToken { + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + name: ByteArray, + symbol: ByteArray, + decimals: u8, + total_supply: u256, + balances: starknet::storage::Map, + allowances: starknet::storage::Map<(ContractAddress, ContractAddress), u256>, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Transfer: Transfer, + Approval: Approval, + } + + #[derive(Drop, starknet::Event)] + pub struct Transfer { + pub from: ContractAddress, + pub to: ContractAddress, + pub value: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Approval { + pub owner: ContractAddress, + pub spender: ContractAddress, + pub value: u256, + } + + #[constructor] + fn constructor(ref self: ContractState, initial_supply: u256, recipient: ContractAddress) { + self.name.write("Stark Token"); + self.symbol.write("STARK"); + self.decimals.write(18); + self.total_supply.write(initial_supply); + self.balances.write(recipient, initial_supply); + self.emit(Event::Transfer(Transfer { from: 0.try_into().unwrap(), to: recipient, value: initial_supply })); + } + + #[abi(embed_v0)] + impl ERC20Impl of crate::erc20::IERC20 { + fn name(self: @ContractState) -> ByteArray { + self.name.read() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.symbol.read() + } + + fn decimals(self: @ContractState) -> u8 { + self.decimals.read() + } + + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.balances.read(account) + } + + fn allowance(self: @ContractState, owner: ContractAddress, spender: ContractAddress) -> u256 { + self.allowances.read((owner, spender)) + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + let sender = get_caller_address(); + self._transfer(sender, recipient, amount); + true + } + + fn transfer_from(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool { + let caller = get_caller_address(); + let current_allowance = self.allowances.read((sender, caller)); + assert(current_allowance >= amount, 'ERC20: insufficient allowance'); + self._approve(sender, caller, current_allowance - amount); + self._transfer(sender, recipient, amount); + true + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + let owner = get_caller_address(); + self._approve(owner, spender, amount); + true + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _transfer(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) { + assert(sender != 0.try_into().unwrap(), 'ERC20: transfer from 0'); + assert(recipient != 0.try_into().unwrap(), 'ERC20: transfer to 0'); + let sender_balance = self.balances.read(sender); + assert(sender_balance >= amount, 'ERC20: insufficient balance'); + self.balances.write(sender, sender_balance - amount); + let recipient_balance = self.balances.read(recipient); + self.balances.write(recipient, recipient_balance + amount); + self.emit(Event::Transfer(Transfer { from: sender, to: recipient, value: amount })); + } + + fn _approve(ref self: ContractState, owner: ContractAddress, spender: ContractAddress, amount: u256) { + assert(owner != 0.try_into().unwrap(), 'ERC20: approve from 0'); + assert(spender != 0.try_into().unwrap(), 'ERC20: approve to 0'); + self.allowances.write((owner, spender), amount); + self.emit(Event::Approval(Approval { owner, spender, value: amount })); + } + } +} \ No newline at end of file diff --git a/starknet_contracts/Scarb.lock b/starknet_contracts/Scarb.lock index fcb24ad..ec780ec 100644 --- a/starknet_contracts/Scarb.lock +++ b/starknet_contracts/Scarb.lock @@ -17,7 +17,7 @@ dependencies = [ ] [[package]] -name = "Starknet_contracts" +name = "starknet_contracts" version = "0.1.0" dependencies = [ "snforge_std", diff --git a/starknet_contracts/Scarb.toml b/starknet_contracts/Scarb.toml index 361c44a..2d8de69 100644 --- a/starknet_contracts/Scarb.toml +++ b/starknet_contracts/Scarb.toml @@ -1,18 +1,18 @@ [package] -name = "Starknet_contracts" +name = "starknet_contracts" version = "0.1.0" edition = "2024_07" # See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html [dependencies] -Starknet = "2.11.4" +starknet = "2.11.4" [dev-dependencies] snforge_std = "0.43.1" assert_macros = "2.11.4" -[[target.Starknet-contract]] +[[target.starknet-contract]] sierra = true [scripts] diff --git a/starknet_contracts/src/contracts/counter.cairo b/starknet_contracts/src/contracts/counter.cairo index a4828e4..0fe6d69 100644 --- a/starknet_contracts/src/contracts/counter.cairo +++ b/starknet_contracts/src/contracts/counter.cairo @@ -1,9 +1,9 @@ -#[Starknet::contract] +#[starknet::contract] pub mod Counter { // use Starknet::ContractAddress; // use Starknet::get_caller_address; - use Starknet_contracts::interfaces::ICounter::ICounter; - use Starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use crate::interfaces::ICounter::ICounter; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; #[storage] struct Storage { @@ -11,13 +11,13 @@ pub mod Counter { } #[event] - #[derive(Drop, Starknet::Event)] + #[derive(Drop, starknet::Event)] pub enum Event { - CountUpdated : CountUpdated, + CountUpdated: CountUpdated, } - #[derive(Drop, Starknet::Event)] - struct CountUpdated { + #[derive(Drop, starknet::Event)] + pub struct CountUpdated { old_value: u32, new_value: u32, } diff --git a/starknet_contracts/src/interfaces/ICounter.cairo b/starknet_contracts/src/interfaces/ICounter.cairo index b5d41f5..f1657e6 100644 --- a/starknet_contracts/src/interfaces/ICounter.cairo +++ b/starknet_contracts/src/interfaces/ICounter.cairo @@ -1,4 +1,4 @@ -#[Starknet::interface] +#[starknet::interface] pub trait ICounter { fn get_count(self: @TContractState) -> u32; fn increment(ref self: TContractState); diff --git a/starknet_contracts/tests/test_contract.cairo b/starknet_contracts/tests/test_contract.cairo deleted file mode 100644 index 151d395..0000000 --- a/starknet_contracts/tests/test_contract.cairo +++ /dev/null @@ -1,47 +0,0 @@ -use Starknet::ContractAddress; - -use snforge_std::{declare, ContractClassTrait, DeclareResultTrait}; - -use Starknet_contracts::IHelloStarknetSafeDispatcher; -use Starknet_contracts::IHelloStarknetSafeDispatcherTrait; -use Starknet_contracts::IHelloStarknetDispatcher; -use Starknet_contracts::IHelloStarknetDispatcherTrait; - -fn deploy_contract(name: ByteArray) -> ContractAddress { - let contract = declare(name).unwrap().contract_class(); - let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap(); - contract_address -} - -#[test] -fn test_increase_balance() { - let contract_address = deploy_contract("HelloStarknet"); - - let dispatcher = IHelloStarknetDispatcher { contract_address }; - - let balance_before = dispatcher.get_balance(); - assert(balance_before == 0, 'Invalid balance'); - - dispatcher.increase_balance(42); - - let balance_after = dispatcher.get_balance(); - assert(balance_after == 42, 'Invalid balance'); -} - -#[test] -#[feature("safe_dispatcher")] -fn test_cannot_increase_balance_with_zero_value() { - let contract_address = deploy_contract("HelloStarknet"); - - let safe_dispatcher = IHelloStarknetSafeDispatcher { contract_address }; - - let balance_before = safe_dispatcher.get_balance().unwrap(); - assert(balance_before == 0, 'Invalid balance'); - - match safe_dispatcher.increase_balance(0) { - Result::Ok(_) => core::panic_with_felt252('Should have panicked'), - Result::Err(panic_data) => { - assert(*panic_data.at(0) == 'Amount cannot be 0', *panic_data.at(0)); - } - }; -} diff --git a/testing/.gitignore b/testing/.gitignore new file mode 100644 index 0000000..4096f8b --- /dev/null +++ b/testing/.gitignore @@ -0,0 +1,5 @@ +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/testing/.tool-versions b/testing/.tool-versions new file mode 100644 index 0000000..0a1e886 --- /dev/null +++ b/testing/.tool-versions @@ -0,0 +1,2 @@ +scarb 2.12.2 +starknet-foundry 0.49.0 diff --git a/testing/Scarb.lock b/testing/Scarb.lock new file mode 100644 index 0000000..1ad3f27 --- /dev/null +++ b/testing/Scarb.lock @@ -0,0 +1,143 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "openzeppelin" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:05fd9365be85a4a3e878135d5c52229f760b3861ce4ed314cb1e75b178b553da" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_governance", + "openzeppelin_introspection", + "openzeppelin_merkle_tree", + "openzeppelin_presets", + "openzeppelin_security", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_access" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:7734901a0ca7a7065e69416fea615dd1dc586c8dc9e76c032f25ee62e8b2a06c" +dependencies = [ + "openzeppelin_introspection", +] + +[[package]] +name = "openzeppelin_account" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:1aa3a71e2f40f66f98d96aa9bf9f361f53db0fd20fa83ef7df04426a3c3a926a" +dependencies = [ + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_finance" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:f0c507fbff955e4180ea3fa17949c0ff85518c40101f4948948d9d9a74143d6c" +dependencies = [ + "openzeppelin_access", + "openzeppelin_token", +] + +[[package]] +name = "openzeppelin_governance" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:c0fb60fad716413d537fabd5fcbb2c499ca6beb95af5f0d1699955ecec4c6f63" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_introspection" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:13e04a2190684e6804229a77a6c56de7d033db8b9ef519e5e8dee400a70d8a3d" + +[[package]] +name = "openzeppelin_merkle_tree" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:039608900e92f3dcf479bf53a49a1fd76452acd97eb86e390d1eb92cacdaf3af" + +[[package]] +name = "openzeppelin_presets" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:5c07a8de32e5d9abe33988c7927eaa8b5f83bc29dc77302d9c8c44c898611042" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_security" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:27155597019ecf971c48d7bfb07fa58cdc146d5297745570071732abca17f19f" + +[[package]] +name = "openzeppelin_token" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:4452f449dc6c1ea97cf69d1d9182749abd40e85bd826cd79652c06a627eafd91" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_upgrades" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:15fdd63f6b50a0fda7b3f8f434120aaf7637bcdfe6fd8d275ad57343d5ede5e1" + +[[package]] +name = "openzeppelin_utils" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:44f32d242af1e43982decc49c563e613a9b67ade552f5c3d5cde504e92f74607" + +[[package]] +name = "snforge_scarb_plugin" +version = "0.49.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:903150f0e9542e4277d417029eea4c03af0db398b581f9f7ae3ebbdac9afc657" + +[[package]] +name = "snforge_std" +version = "0.49.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:73d73653cc4356ec51b92a6bec9d8385b20318170c2f2ade7891e5185a0e7e64" +dependencies = [ + "snforge_scarb_plugin", +] + +[[package]] +name = "test" +version = "0.1.0" +dependencies = [ + "openzeppelin", + "snforge_std", +] diff --git a/testing/Scarb.toml b/testing/Scarb.toml new file mode 100644 index 0000000..30df082 --- /dev/null +++ b/testing/Scarb.toml @@ -0,0 +1,53 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024_07" + +# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + +[dependencies] +starknet = "2.12.2" +openzeppelin = "0.20.0" + +[dev-dependencies] +snforge_std = "0.49.0" +assert_macros = "2.12.2" + +[[target.starknet-contract]] +sierra = true + +[scripts] +test = "snforge test" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std"] + +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/scarb-toml.html for more information + +# [tool.snforge] # Define `snforge` tool section +# exit_first = true # Stop tests execution immediately upon the first failure +# fuzzer_runs = 1234 # Number of runs of the random fuzzer +# fuzzer_seed = 1111 # Seed for the random fuzzer + +# [[tool.snforge.fork]] # Used for fork testing +# name = "SOME_NAME" # Fork name +# url = "http://your.rpc.url" # Url of the RPC provider +# block_id.tag = "latest" # Block to fork from (block tag) + +# [[tool.snforge.fork]] +# name = "SOME_SECOND_NAME" +# url = "http://your.second.rpc.url" +# block_id.number = "123" # Block to fork from (block number) + +# [[tool.snforge.fork]] +# name = "SOME_THIRD_NAME" +# url = "http://your.third.rpc.url" +# block_id.hash = "0x123" # Block to fork from (block hash) + +# [profile.dev.cairo] # Configure Cairo compiler +# unstable-add-statements-code-locations-debug-info = true # Should be used if you want to use coverage +# unstable-add-statements-functions-debug-info = true # Should be used if you want to use coverage/profiler +# inlining-strategy = "avoid" # Should be used if you want to use coverage + +# [features] # Used for conditional compilation +# enable_for_tests = [] # Feature name and list of other features that should be enabled with it diff --git a/testing/snfoundry.toml b/testing/snfoundry.toml new file mode 100644 index 0000000..0f29e90 --- /dev/null +++ b/testing/snfoundry.toml @@ -0,0 +1,11 @@ +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/snfoundry-toml.html +# and https://foundry-rs.github.io/starknet-foundry/projects/configuration.html for more information + +# [sncast.default] # Define a profile name +# url = "https://starknet-sepolia.public.blastapi.io/rpc/v0_8" # Url of the RPC provider +# accounts-file = "../account-file" # Path to the file with the account data +# account = "mainuser" # Account from `accounts_file` or default account file that will be used for the transactions +# keystore = "~/keystore" # Path to the keystore file +# wait-params = { timeout = 300, retry-interval = 10 } # Wait for submitted transaction parameters +# block-explorer = "StarkScan" # Block explorer service used to display links to transaction details +# show-explorer-links = true # Print links pointing to pages with transaction details in the chosen block explorer diff --git a/testing/src/RewardToken.cairo b/testing/src/RewardToken.cairo new file mode 100644 index 0000000..b71c8b1 --- /dev/null +++ b/testing/src/RewardToken.cairo @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IExternal { + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256); +} +#[starknet::contract] +pub mod RewardERC20 { + use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::token::erc20::interface::IERC20Metadata; + use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + + #[storage] + pub struct Storage { + #[substorage(v0)] + pub erc20: ERC20Component::Storage, + #[substorage(v0)] + pub ownable: OwnableComponent::Storage, + custom_decimals: u8 // Add custom decimals storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event, + #[flat] + OwnableEvent: OwnableComponent::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, owner: ContractAddress, name: ByteArray, symbol: ByteArray, + ) { + self.erc20.initializer(name, symbol); + self.ownable.initializer(owner); + self.custom_decimals.write(8); + } + + #[abi(embed_v0)] + impl CustomERC20MetadataImpl of IERC20Metadata { + fn name(self: @ContractState) -> ByteArray { + self.erc20.name() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.erc20.symbol() + } + + fn decimals(self: @ContractState) -> u8 { + self.custom_decimals.read() // Return custom value + } + } + + // Keep existing implementations + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableImpl; + impl InternalImpl = ERC20Component::InternalImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + #[abi(embed_v0)] + impl ExternalImpl of super::IExternal { + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { + self.erc20.mint(recipient, amount); + } + } +} diff --git a/testing/src/StakingContract.cairo b/testing/src/StakingContract.cairo new file mode 100644 index 0000000..1621b01 --- /dev/null +++ b/testing/src/StakingContract.cairo @@ -0,0 +1,272 @@ +#[starknet::contract] +mod Staking { + use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::security::pausable::PausableComponent; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; + use test::interfaces::IStaking::IStaking; + use test::types::StakeDetails; + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: PausableComponent, storage: pausable, event: PausableEvent); + + // Ownable Mixin + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + // Pausable Mixin + #[abi(embed_v0)] + impl PausableImpl = PausableComponent::PausableImpl; + impl PausableInternalImpl = PausableComponent::InternalImpl; + + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + pausable: PausableComponent::Storage, + // ERC20 token addresses + stark_token: ContractAddress, + reward_token: ContractAddress, + duration: u64, + // Reward distribution state + reward_rate: u256, // rewards per second + reward_per_token_stored: u256, // cumulative reward per token + last_update_time: u64, // last time reward_per_token_stored was updated + period_finish: u64, // end time of current reward period + // User state + user_reward_per_token_paid: Map, // reward per token paid to user + rewards: Map, // pending rewards for user + balances: Map, // staked balances + stake_count: u256, + stakes: Map, + // Global state + total_supply: u256 // total staked tokens + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + PausableEvent: PausableComponent::Event, + Staked: Staked, + Unstaked: Unstaked, + RewardPaid: RewardPaid, + RewardsFunded: RewardsFunded, + RecoveredTokens: RecoveredTokens, + } + + + #[derive(Drop, starknet::Event)] + struct Staked { + #[key] + user: ContractAddress, + amount: u256, + } + + #[derive(Drop, starknet::Event)] + struct Unstaked { + #[key] + user: ContractAddress, + amount: u256, + } + + #[derive(Drop, starknet::Event)] + struct RewardPaid { + #[key] + user: ContractAddress, + reward: u256, + } + + #[derive(Drop, starknet::Event)] + struct RewardsFunded { + amount: u256, + duration: u64, + } + + #[derive(Drop, starknet::Event)] + struct RecoveredTokens { + token: ContractAddress, + amount: u256, + #[key] + to: ContractAddress, + } + + + #[constructor] + fn constructor( + ref self: ContractState, reward_token: ContractAddress, stark_token: ContractAddress, + ) { + self.stark_token.write(stark_token); + self.reward_token.write(reward_token); + } + + #[abi(embed_v0)] + impl StakingImpl of IStaking { + /// Stake tokens to earn rewards + fn stake(ref self: ContractState, amount: u256, duration: u64) -> u256 { + assert(amount > 0, 'Amount must be > 0'); + + let caller = get_caller_address(); + + let id = self.stake_count.read() + 1; + + // Transfer tokenget_block_timestamps from user to contract + let stark_token = IERC20Dispatcher { contract_address: self.stark_token.read() }; + stark_token.transfer_from(caller, get_contract_address(), amount); + + // Update user balance and total supply + let current_balance = self.balances.read(caller); + self.balances.write(caller, current_balance - amount); + let current_total = self.total_supply.read(); + self.total_supply.write(current_total + amount); + + let stake_details = StakeDetails { id, owner: caller, duration, amount, valid: true }; + + self.stakes.write(id, stake_details); + self.stake_count.write(id); + + self.emit(Staked { user: caller, amount }); + + id + } + + fn get_stake_details(self: @ContractState, id: u256) -> StakeDetails { + let stake = self.stakes.read(id); + stake + } + + /// Unstake tokens + fn unstake(ref self: ContractState, amount: u256) { + self.pausable.assert_not_paused(); + assert(amount > 0, 'Amount must be > 0'); + + let caller = get_caller_address(); + let current_balance = self.balances.read(caller); + assert(current_balance >= amount, 'Insufficient balance'); + + self.update_reward(caller); + + // Update user balance and total supply + self.balances.write(caller, current_balance + amount); + let current_total = self.total_supply.read(); + self.total_supply.write(current_total - amount); + + // Transfer tokens back to user + let stark_token = IERC20Dispatcher { contract_address: self.stark_token.read() }; + stark_token.transfer(caller, amount); + + self.emit(Unstaked { user: caller, amount }); + } + + fn get_strk_address(self: @ContractState) -> ContractAddress { + self.stark_token.read() + } + fn get_reward_address(self: @ContractState) -> ContractAddress { + self.reward_token.read() + } + + + /// Claim accumulated rewards + fn claim_rewards(ref self: ContractState) { + let caller = get_caller_address(); + + let reward = self.rewards.read(caller); + assert(reward > 0, 'No rewards to claim'); + + self.rewards.write(caller, 0); + + // Transfer reward tokens to user + let reward_token = IERC20Dispatcher { contract_address: self.reward_token.read() }; + reward_token.transfer(caller, reward); + + self.emit(RewardPaid { user: caller, reward }); + } + + /// Get earned rewards for an account + fn earned(self: @ContractState, account: ContractAddress) -> u256 { + let balance = self.balances.read(account); + let reward_per_token = self.reward_per_token(); + let user_paid = self.user_reward_per_token_paid.read(account); + let pending = self.rewards.read(account); + + balance * (reward_per_token - user_paid) / 1_000_000_000_000_000_000 + pending + } + + /// Get staked balance for an account + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.balances.read(account) + } + + /// Get total staked tokens + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + /// Get last time reward was applicable + fn last_time_reward_applicable(self: @ContractState) -> u64 { + let current_time = get_block_timestamp(); + let finish = self.period_finish.read(); + if current_time < finish { + current_time + } else { + finish + } + } + + /// Get current reward per token + fn reward_per_token(self: @ContractState) -> u256 { + let total_supply = self.total_supply.read(); + if total_supply == 0 { + self.reward_per_token_stored.read() + } else { + let last_time = self.last_time_reward_applicable(); + let last_update = self.last_update_time.read(); + let time_diff = last_time - last_update; + let reward_rate = self.reward_rate.read(); + + self.reward_per_token_stored.read() + + (reward_rate * time_diff.into() * 1_000_000_000_000_000_000) / total_supply + } + } + } + + + #[generate_trait] + impl InternalImpl of InternalTrait { + /// Update reward for a specific account + fn update_reward(ref self: ContractState, account: ContractAddress) { + let reward_per_token = self.reward_per_token(); + self.reward_per_token_stored.write(reward_per_token); + self.last_update_time.write(self.last_time_reward_applicable()); + + let zero_address = 0.try_into().unwrap(); + if account != zero_address { + let balance = self.balances.read(account); + let user_paid = self.user_reward_per_token_paid.read(account); + self + .rewards + .write( + account, + balance * (reward_per_token - user_paid) / 1_000_000_000_000_000_000 + + self.rewards.read(account), + ); + self.user_reward_per_token_paid.write(account, reward_per_token); + } + } + + /// Update global reward per token stored + fn update_reward_per_token_stored(ref self: ContractState) { + let reward_per_token = self.reward_per_token(); + self.reward_per_token_stored.write(reward_per_token); + self.last_update_time.write(self.last_time_reward_applicable()); + } + } +} diff --git a/testing/src/interfaces/ICounter.cairo b/testing/src/interfaces/ICounter.cairo new file mode 100644 index 0000000..dcd920e --- /dev/null +++ b/testing/src/interfaces/ICounter.cairo @@ -0,0 +1,6 @@ +#[Starknet::interface] +pub trait ICounter { + fn get_count(self: @TContractState) -> u32; + fn increment(ref self: TContractState); + fn decrement(ref self: TContractState); +} diff --git a/testing/src/interfaces/IHelloStarknet.cairo b/testing/src/interfaces/IHelloStarknet.cairo new file mode 100644 index 0000000..251c45a --- /dev/null +++ b/testing/src/interfaces/IHelloStarknet.cairo @@ -0,0 +1,9 @@ +/// Interface representing `HelloContract`. +/// This interface allows modification and retrieval of the contract balance. +#[Starknet::interface] +pub trait IHelloStarknet { + /// Increase contract balance. + fn increase_balance(ref self: TContractState, amount: felt252); + /// Retrieve contract balance. + fn get_balance(self: @TContractState) -> felt252; +} diff --git a/testing/src/interfaces/IOwnerFunctions.cairo b/testing/src/interfaces/IOwnerFunctions.cairo new file mode 100644 index 0000000..15b167d --- /dev/null +++ b/testing/src/interfaces/IOwnerFunctions.cairo @@ -0,0 +1,9 @@ +use starknet::ContractAddress; + +#[starknet::interface] +trait IOwnerFunctions { + fn fund_rewards(ref self: TContractState, amount: u256, duration: u64); + fn pause(ref self: TContractState); + fn unpause(ref self: TContractState); + fn recover_erc20(ref self: TContractState, token: ContractAddress, amount: u256); +} diff --git a/testing/src/interfaces/IStaking.cairo b/testing/src/interfaces/IStaking.cairo new file mode 100644 index 0000000..3184c78 --- /dev/null +++ b/testing/src/interfaces/IStaking.cairo @@ -0,0 +1,17 @@ +use starknet::ContractAddress; +use test::types::StakeDetails; +// Interfaces +#[starknet::interface] +pub trait IStaking { + fn stake(ref self: TContractState, amount: u256, duration: u64) -> u256; + fn unstake(ref self: TContractState, amount: u256); + fn claim_rewards(ref self: TContractState); + fn earned(self: @TContractState, account: ContractAddress) -> u256; + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn total_supply(self: @TContractState) -> u256; + fn last_time_reward_applicable(self: @TContractState) -> u64; + fn reward_per_token(self: @TContractState) -> u256; + fn get_stake_details(self: @TContractState, id: u256) -> StakeDetails; + fn get_strk_address(self: @TContractState) -> ContractAddress; + fn get_reward_address(self: @TContractState) -> ContractAddress; +} diff --git a/testing/src/lib.cairo b/testing/src/lib.cairo new file mode 100644 index 0000000..cf61f93 --- /dev/null +++ b/testing/src/lib.cairo @@ -0,0 +1,8 @@ +pub mod RewardToken; +pub mod StakingContract; + +pub mod interfaces { + pub mod IStaking; +} + +pub mod types; diff --git a/testing/src/types.cairo b/testing/src/types.cairo new file mode 100644 index 0000000..4ef7be2 --- /dev/null +++ b/testing/src/types.cairo @@ -0,0 +1,10 @@ +use starknet::ContractAddress; + +#[derive(Drop, Serde, starknet::Store)] +pub struct StakeDetails { + pub id: u256, + pub owner: ContractAddress, + pub duration: u64, + pub amount: u256, + pub valid: bool, +} diff --git a/testing/tests/test_contract.cairo b/testing/tests/test_contract.cairo new file mode 100644 index 0000000..08dbf03 --- /dev/null +++ b/testing/tests/test_contract.cairo @@ -0,0 +1,233 @@ +use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address, + stop_cheat_caller_address, +}; +use starknet::{ContractAddress, contract_address_const}; +use test::RewardToken::{IExternalDispatcher, IExternalDispatcherTrait}; +use test::interfaces::IStaking::{IStakingDispatcher, IStakingDispatcherTrait}; + +fn deploy_contract() -> (IStakingDispatcher, ContractAddress, ContractAddress) { + let contract = declare("Staking").unwrap().contract_class(); + // Define constructor calldata + let (strk_address, reward_address) = deploy_erc20(); + let mut constructor_args = array![reward_address.into(), strk_address.into()]; + + let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); + + (IStakingDispatcher { contract_address }, strk_address, reward_address) +} + +fn deploy_erc20() -> (ContractAddress, ContractAddress) { + let owner: ContractAddress = contract_address_const::<'aji'>(); + let name: ByteArray = "STRK"; + let sym: ByteArray = "Sym"; + let reward: ByteArray = "Reward"; + let reward_sym: ByteArray = "RWD"; + // Deploy mock ERC20 + let erc20_class = declare("RewardERC20").unwrap().contract_class(); + + // Pass ByteArray directly for name and symbol + let mut calldata = ArrayTrait::new(); + owner.serialize(ref calldata); + name.serialize(ref calldata); + sym.serialize(ref calldata); + + let (strk_address, _) = erc20_class.deploy(@calldata).unwrap(); + + let mut usdc_calldata = ArrayTrait::new(); + owner.serialize(ref usdc_calldata); + reward.serialize(ref usdc_calldata); + reward_sym.serialize(ref usdc_calldata); + let (reward_address, _) = erc20_class.deploy(@usdc_calldata).unwrap(); + + (strk_address, reward_address) +} + + +#[test] +fn test_deployment() { + let (dispatcher, strk_address, reward_address) = deploy_contract(); + let s_address = dispatcher.get_strk_address(); + let r_address = dispatcher.get_reward_address(); + + assert(s_address == strk_address, 'invalid strk address'); + assert(r_address == reward_address, 'invalid reward address'); +} + +#[test] +fn test_stake() { + let (dispatcher, strk_address, _) = deploy_contract(); + let caller: ContractAddress = contract_address_const::<'aji'>(); + let stake_amount: u256 = 1000; + let stake_duration: u64 = 60 * 60 * 24 * 7; // 1 week + + // Mint some STRK to caller + let strk_mint = IExternalDispatcher { contract_address: strk_address }; + strk_mint.mint(caller, 10000); + + let strk = IERC20Dispatcher { contract_address: strk_address }; + let initial_balance = strk.balance_of(caller); + + start_cheat_caller_address(strk_address, caller); + // Approve staking contract to spend caller's STRK + strk.approve(dispatcher.contract_address, stake_amount); + let allowance = strk.allowance(caller, dispatcher.contract_address); + stop_cheat_caller_address(strk_address); + + println!("Allowance: {}", allowance); + println!("Initial Balance: {}", initial_balance); + + start_cheat_caller_address(dispatcher.contract_address, caller); + // Stake tokens + let stake_id = dispatcher.stake(stake_amount, stake_duration); + let post_stake_balance = strk.balance_of(caller); + + let p_allowance = strk.allowance(caller, dispatcher.contract_address); + println!("Allowance after stake: {}", p_allowance); + println!("Post stake Balance: {}", post_stake_balance); + + assert(post_stake_balance == initial_balance - stake_amount, 'stake failed'); + let contract_balance = strk.balance_of(dispatcher.contract_address); + assert(contract_balance == stake_amount, 'contract balance incorrect'); + + let staked_balance = dispatcher.balance_of(caller); + assert(staked_balance == stake_amount, 'staked balance incorrect'); + + // Get stake details + let stake_details = dispatcher.get_stake_details(stake_id); + assert(stake_details.owner == caller, 'stake owner incorrect'); + assert(stake_details.amount == stake_amount, 'stake amount incorrect'); + assert(stake_details.duration == stake_duration, 'stake duration incorrect'); + assert(stake_details.valid, 'stake valid incorrect'); +} + +#[test] +fn test_Unstake() { + let (dispatcher, strk_address, _) = deploy_contract(); + let caller: ContractAddress = contract_address_const::<'aji'>(); + let stake_amount: u256 = 1000; + let unstake_amount: u256 = 500; + let stake_duration: u64 = 60 * 60 * 24 * 7; // 1 week + + // Mint some STRK to caller + let strk_mint = IExternalDispatcher { contract_address: strk_address }; + strk_mint.mint(caller, 10000); + + let strk = IERC20Dispatcher { contract_address: strk_address }; + let initial_balance = strk.balance_of(caller); + + start_cheat_caller_address(strk_address, caller); + // Approve staking contract to spend caller's STRK + strk.approve(dispatcher.contract_address, stake_amount); + stop_cheat_caller_address(strk_address); + + start_cheat_caller_address(dispatcher.contract_address, caller); + // Stake tokens + let _ = dispatcher.stake(stake_amount, stake_duration); + let post_stake_balance = strk.balance_of(caller); + assert(post_stake_balance == initial_balance - stake_amount, 'stake failed'); + + // Now unstake + dispatcher.unstake(unstake_amount); + let post_unstake_balance = strk.balance_of(caller); + stop_cheat_caller_address(dispatcher.contract_address); + + // Check user balance increased by unstake_amount + assert(post_unstake_balance == post_stake_balance + unstake_amount, 'unstake failed'); + + // Check contract balance decreased + let contract_balance = strk.balance_of(dispatcher.contract_address); + assert(contract_balance == stake_amount - unstake_amount, 'contract balance incorrect'); + + // Check staked balance decreased + let staked_balance = dispatcher.balance_of(caller); + assert(staked_balance == stake_amount - unstake_amount, 'staked balance incorrect'); +} + +#[test] +fn test_balance_of() { + let (dispatcher, strk_address, _) = deploy_contract(); + let caller: ContractAddress = contract_address_const::<'aji'>(); + let stake_amount: u256 = 1000; + + // Mint and stake + let strk_mint = IExternalDispatcher { contract_address: strk_address }; + strk_mint.mint(caller, 10000); + + let strk = IERC20Dispatcher { contract_address: strk_address }; + start_cheat_caller_address(strk_address, caller); + strk.approve(dispatcher.contract_address, stake_amount); + stop_cheat_caller_address(strk_address); + + start_cheat_caller_address(dispatcher.contract_address, caller); + let _ = dispatcher.stake(stake_amount, 60 * 60 * 24 * 7); + stop_cheat_caller_address(dispatcher.contract_address); + + let balance = dispatcher.balance_of(caller); + assert(balance == stake_amount, 'balance_of incorrect'); +} + +#[test] +fn test_total_supply() { + let (dispatcher, strk_address, _) = deploy_contract(); + let caller: ContractAddress = contract_address_const::<'aji'>(); + let stake_amount: u256 = 1000; + + let initial_supply = dispatcher.total_supply(); + assert(initial_supply == 0, 'initial supply not zero'); + + // Mint and stake + let strk_mint = IExternalDispatcher { contract_address: strk_address }; + strk_mint.mint(caller, 10000); + + let strk = IERC20Dispatcher { contract_address: strk_address }; + start_cheat_caller_address(strk_address, caller); + strk.approve(dispatcher.contract_address, stake_amount); + stop_cheat_caller_address(strk_address); + + start_cheat_caller_address(dispatcher.contract_address, caller); + let _ = dispatcher.stake(stake_amount, 60 * 60 * 24 * 7); + stop_cheat_caller_address(dispatcher.contract_address); + + let supply = dispatcher.total_supply(); + assert(supply == stake_amount, 'total_supply incorrect'); +} + +#[test] +fn test_earned() { + let (dispatcher, _, _) = deploy_contract(); + let caller: ContractAddress = contract_address_const::<'aji'>(); + + // No staking, no rewards + let earned_amount = dispatcher.earned(caller); + assert(earned_amount == 0, 'earned should be zero initially'); +} + +#[test] +fn test_reward_per_token() { + let (dispatcher, _, _) = deploy_contract(); + + let rpt = dispatcher.reward_per_token(); + assert(rpt == 0, 'reward_per_token should be zero'); +} + +#[test] +fn test_last_time_reward_applicable() { + let (dispatcher, _, _) = deploy_contract(); + + let ltra = dispatcher.last_time_reward_applicable(); + // Since period_finish is 0, and current_time > 0, should return 0 + assert(ltra == 0, 'ltra incorrect'); +} + +#[test] +#[should_panic(expected: ('No rewards to claim',))] +fn test_claim_rewards_no_rewards() { + let (dispatcher, _, _) = deploy_contract(); + let caller: ContractAddress = contract_address_const::<'aji'>(); + + start_cheat_caller_address(dispatcher.contract_address, caller); + dispatcher.claim_rewards(); + stop_cheat_caller_address(dispatcher.contract_address); +}