diff --git a/starknet_contracts/.gitignore b/starknet_contracts/.gitignore new file mode 100644 index 0000000..4096f8b --- /dev/null +++ b/starknet_contracts/.gitignore @@ -0,0 +1,5 @@ +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/starknet_contracts/Scarb.lock b/starknet_contracts/Scarb.lock new file mode 100644 index 0000000..ec780ec --- /dev/null +++ b/starknet_contracts/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "snforge_scarb_plugin" +version = "0.43.1" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:178e1e2081003ae5e40b5a8574654bed15acbd31cce651d4e74fe2f009bc0122" + +[[package]] +name = "snforge_std" +version = "0.43.1" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:17bc65b0abfb9b174784835df173f9c81c9ad39523dba760f30589ef53cf8bb5" +dependencies = [ + "snforge_scarb_plugin", +] + +[[package]] +name = "starknet_contracts" +version = "0.1.0" +dependencies = [ + "snforge_std", +] diff --git a/starknet_contracts/Scarb.toml b/starknet_contracts/Scarb.toml new file mode 100644 index 0000000..ef1df6a --- /dev/null +++ b/starknet_contracts/Scarb.toml @@ -0,0 +1,52 @@ +[package] +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" + +[dev-dependencies] +snforge_std = "0.43.1" +assert_macros = "2.11.4" + +[[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/starknet_contracts/snfoundry.toml b/starknet_contracts/snfoundry.toml new file mode 100644 index 0000000..0f29e90 --- /dev/null +++ b/starknet_contracts/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/starknet_contracts/src/contracts/HelloStarknet.cairo b/starknet_contracts/src/contracts/HelloStarknet.cairo new file mode 100644 index 0000000..c8b242f --- /dev/null +++ b/starknet_contracts/src/contracts/HelloStarknet.cairo @@ -0,0 +1,52 @@ +/// Simple contract for managing balance. +#[starknet::contract] +pub mod HelloStarknet { + + use starknet_contracts::interfaces::IHelloStarknet::IHelloStarknet; + // use starknet::storage::{StoragePointerReadAccess, StoragePathEntry, StoragePointerWriteAccess, Map }; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, StoragePointerWriteAccess }; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + balance: felt252, + balances: Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Balance : BalanceIncreased, + } + + #[derive(Drop, starknet::Event)] + pub struct BalanceIncreased { + pub caller: ContractAddress, + pub amount: felt252, + } + + #[abi(embed_v0)] + impl HelloStarknetImpl of IHelloStarknet { + fn increase_balance(ref self: ContractState, amount: felt252) { + assert(amount != 0, 'Amount cannot be 0'); + let caller = get_caller_address(); + + let updated_amount = self.balance.read() + amount; + self.balance.write(updated_amount); + + // let unique_balance = self.balances.entry(caller).read(); + + let unique_balance = self.balances.read(caller); + // self.balances.entry(caller).write(unique_balance + amount); + self.balances.write(caller, unique_balance + amount); + + // self.balance.write(self.balance.read() + amount); + + self.emit(BalanceIncreased{caller, amount}); + } + + fn get_balance(self: @ContractState) -> felt252 { + self.balance.read() + } + } +} \ No newline at end of file diff --git a/starknet_contracts/src/contracts/counter.cairo b/starknet_contracts/src/contracts/counter.cairo new file mode 100644 index 0000000..6a5e6fe --- /dev/null +++ b/starknet_contracts/src/contracts/counter.cairo @@ -0,0 +1,51 @@ +#[starknet::contract] +pub mod Counter { + // use starknet::ContractAddress; + // use starknet::get_caller_address; + use starknet_contracts::interfaces::ICounter::ICounter; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + #[storage] + struct Storage { + count: u32, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + CountUpdated : CountUpdated, + } + + #[derive(Drop, starknet::Event)] + struct CountUpdated { + old_value: u32, + new_value: u32, + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.count.write(0); + } + + #[abi(embed_v0)] + impl CounterImpl of ICounter { + fn get_count(self: @ContractState) -> u32 { + self.count.read() + } + + fn increment(ref self: ContractState) { + let old_value = self.count.read(); + let new_value = old_value + 1; + self.count.write(new_value); + self.emit(CountUpdated { old_value, new_value }); + } + + fn decrement(ref self: ContractState) { + let old_value = self.count.read(); + assert(old_value > 0, 'Count cannot be negative'); + let new_value = old_value - 1; + self.count.write(new_value); + self.emit(CountUpdated { old_value, new_value }); + } + } +} \ No newline at end of file diff --git a/starknet_contracts/src/interfaces/ICounter.cairo b/starknet_contracts/src/interfaces/ICounter.cairo new file mode 100644 index 0000000..f1657e6 --- /dev/null +++ b/starknet_contracts/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); +} \ No newline at end of file diff --git a/starknet_contracts/src/interfaces/IHelloStarknet.cairo b/starknet_contracts/src/interfaces/IHelloStarknet.cairo new file mode 100644 index 0000000..979b66e --- /dev/null +++ b/starknet_contracts/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; +} \ No newline at end of file diff --git a/starknet_contracts/src/lib.cairo b/starknet_contracts/src/lib.cairo new file mode 100644 index 0000000..7eeead4 --- /dev/null +++ b/starknet_contracts/src/lib.cairo @@ -0,0 +1,9 @@ +pub mod interfaces{ + pub mod IHelloStarknet; + pub mod ICounter; +} + +pub mod contracts{ + pub mod HelloStarknet; + pub mod counter; +} diff --git a/starknet_contracts/task.md b/starknet_contracts/task.md new file mode 100644 index 0000000..1186447 --- /dev/null +++ b/starknet_contracts/task.md @@ -0,0 +1,91 @@ +# StarkNet Staking Contract Assignment + +**Title:** Staking Stark Token on StarkNet — Earn ERC20 Rewards + +**Summary** +Create a secure staking smart contract in **Cairo** for StarkNet where users stake the `Stark` token (an ERC20 on StarkNet) and receive rewards in another ERC20 token (e.g., `RewardToken`). +--- + +## Objectives + +* Implement a gas‑efficient and secure staking contract. +* Support staking, unstaking, and reward distribution based on stake share and time. +* Write comprehensive unit tests and a deployment script using **Starknet Foundry (snforge)**. +* Provide clear documentation and a README explaining design choices and test results. + +--- + + +## Deliverables + +1. `staking.cairo` — Cairo contract implementing staking functionality. +2. `stark_token.cairo` and `reward_token.cairo` — simple ERC20 tokens for local testing. +3. (OPTIONAL) Deployment script to a StarkNet testnet (Sepolia or Testnet2). +4. `README.md` with instructions to run tests, deploy locally, and explanation of reward formula and security considerations. +5. Optional: small frontend demo (bonus). + +--- + +## Functional Requirements + +* Users can `stake(amount: u256)` Stark tokens by transferring them to the staking contract (use ERC20 `transfer_from`). +* Users can `unstake(amount: u256)` and receive their principal back. +* Rewards are paid in a separate ERC20 `RewardToken` and accrue over time. +* Reward calculation must be fair: rewards based on stake share and time. Implement one of the following approaches: + + * **Per-second reward rate**: fixed reward rate distributed proportionally to staked balance over time using `get_block_timestamp()`. + * **Reward per token stored**: maintain cumulative reward per staked token and track each user’s paid rewards. +* Users can `claim_rewards()` to withdraw accumulated reward tokens. +* Owner/manager functions: + + * `fund_rewards(amount: u256, duration: u64)` to top up reward pool and set distribution duration. + * `pause()` / `unpause()` (recommended) to halt staking in emergencies. + * `recover_erc20(token: ContractAddress, amount: u256)` to rescue mistakenly sent tokens (with restrictions: cannot rescue staked or reward tokens while active distribution). +* Emit events: `Staked`, `Unstaked`, `RewardPaid`, `RewardsFunded`, `Paused`, `Unpaused`, `RecoveredTokens`. + +--- + +## Non-functional & Security Requirements + +* Protect against reentrancy (use `#[external(v0)]` best practices and checks‑effects‑interactions pattern). +* Correct use of ERC20 `transfer_from` & token approvals. +* Gas efficiency: minimize storage writes; use felt252/u256 carefully. +* Provide reasoning in README about edge cases (e.g., insufficient reward pool, rounding behavior, leftover rewards after distribution period). + +--- + +## Test Cases (minimum) + +1. **Staking and balances** + + * User stakes N tokens: contract balance increases, user stake recorded, total_staked updated. +2. **Unstaking and principal return** + + * User unstakes N tokens: receives N tokens back, balances updated. +3. **Reward accrual over time** + + * Fund rewards for a duration; advance block timestamp; verify `earned()` matches expected formula for single staker and multiple stakers. +4. **Multiple stakers, proportional rewards** + + * Two stakers stake different amounts at different times; simulate time progression and verify correct reward share. +5. **Claiming rewards** + + * After accrual, user calls `claim_rewards()` and receives correct `RewardToken` amount. +6. **Edge cases** + + * Staking 0 should revert. + * Unstaking more than staked should revert. + * Claiming with no rewards should not revert and pay zero. +7. **Security behaviors** + + * Attempted reentrancy should fail. + * Owner-only functions revert for non-owners. + +--- + +## Submission Instructions + +* Create a Pull request on this repository. +* Include all source, tests, and README. + +**Good luck building on StarkNet!** diff --git a/starknet_contracts/tests/test_contract.cairo b/starknet_contracts/tests/test_contract.cairo new file mode 100644 index 0000000..97ad858 --- /dev/null +++ b/starknet_contracts/tests/test_contract.cairo @@ -0,0 +1,47 @@ +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)); + } + }; +}