diff --git a/cairo_program/src/hello_world.cairo b/cairo_program/src/hello_world.cairo index 1739c6d..77754c4 100644 --- a/cairo_program/src/hello_world.cairo +++ b/cairo_program/src/hello_world.cairo @@ -1,6 +1,6 @@ #[executable] fn main() { - let x: felt252 = 32; + let x: u8 = 32; println!("x is: {}", x); println!("Hello, World!"); } diff --git a/cairo_program/src/short_string.cairo b/cairo_program/src/short_string.cairo index 6674470..6e85931 100644 --- a/cairo_program/src/short_string.cairo +++ b/cairo_program/src/short_string.cairo @@ -1,5 +1,5 @@ #[executable] fn main() { - let bootcamp_name: felt252 = 'Bootcamp 6.0'; + let bootcamp_name: ByteArray = "Bootcamp 6.0"; println!("bootcamp name is: {}", bootcamp_name); } diff --git a/erc20_token/.gitignore b/erc20_token/.gitignore new file mode 100644 index 0000000..4096f8b --- /dev/null +++ b/erc20_token/.gitignore @@ -0,0 +1,5 @@ +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/erc20_token/README.md b/erc20_token/README.md new file mode 100644 index 0000000..b354200 --- /dev/null +++ b/erc20_token/README.md @@ -0,0 +1,45 @@ +# Restricted ERC20 Token (Starknet) + +A simple ERC20 token on Starknet with transfer limits and admin controls. Built with Cairo and [Scarb](https://docs.swmansion.com/scarb/). + +## Features + +| Feature | Description | +|---------|-------------| +| **ERC20** | `transfer`, `approve`, `transfer_from`, balances, supply | +| **Transfer cap** | Each transfer must be ≤ `max_limit` (default **10,000**) | +| **Admin** | Set at deploy via constructor — not the UDC deployer | +| **Revoke** | User who approved can cancel their allowance (`revoke`) — [revoke.cash](https://revoke.cash) style | +| **Admin burn** | Owner can destroy tokens from any address | +| **Update limit** | Owner can change `max_limit` | + + + +## Build & test + +```bash +scarb build +scarb test +``` + +## Deploy (constructor args) + +Pass addresses and token settings in this order: + +1. `admin` — admin address (stored as `owner`) +2. `recipient` — initial token holder +3. `name` — token name (`ByteArray`) +4. `symbol` — ticker (`ByteArray`) +5. `decimals` — e.g. `18` +6. `initial_supply` — tokens minted to `recipient` + +Contract module name for declare: **`ERC20Token`** + +## Who can call what + +| Function | Caller | +|----------|--------| +| `transfer`, `approve`, `revoke` | Any token holder | +| `transfer_from` | Approved spender | +| `burn`, `set_max_limit` | Admin (`owner`) | +| `get_owner`, `balance_of`, … | Anyone (read-only) | diff --git a/erc20_token/Scarb.lock b/erc20_token/Scarb.lock new file mode 100644 index 0000000..989516c --- /dev/null +++ b/erc20_token/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "erc20_token" +version = "0.1.0" +dependencies = [ + "snforge_std", +] + +[[package]] +name = "snforge_scarb_plugin" +version = "0.59.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:871fba677c03b66a1bf40815dac0ab1b385eb1b9be6e6c3cf2ad9788eeb2b6bb" + +[[package]] +name = "snforge_std" +version = "0.59.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:3620924fa08bd2d740b2b5b01ef86c8dab3d4b9c2206387c8dbdc8d2ec15133e" +dependencies = [ + "snforge_scarb_plugin", +] diff --git a/erc20_token/Scarb.toml b/erc20_token/Scarb.toml new file mode 100644 index 0000000..e9285a2 --- /dev/null +++ b/erc20_token/Scarb.toml @@ -0,0 +1,52 @@ +[package] +name = "erc20_token" +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.17.0" + +[dev-dependencies] +snforge_std = "0.59.0" +assert_macros = "2.17.0" + +[[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/erc20_token/snfoundry.toml b/erc20_token/snfoundry.toml new file mode 100644 index 0000000..686c2ab --- /dev/null +++ b/erc20_token/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://api.zan.top/public/starknet-sepolia/rpc/v0_10" # 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 = "Voyager" # 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/erc20_token/src/checks.cairo b/erc20_token/src/checks.cairo new file mode 100644 index 0000000..982a4d2 --- /dev/null +++ b/erc20_token/src/checks.cairo @@ -0,0 +1,21 @@ +pub fn assert_within_limit(amount: u256, max_limit: u256) { + if amount > max_limit { + assert(false, 'Over limit'); + } +} + +pub fn assert_enough_balance(balance: u256, amount: u256) { + if balance < amount { + assert(false, 'Low balance'); + } +} + +pub fn assert_enough_allowance(allowed: u256, amount: u256) { + if allowed < amount { + assert(false, 'Low allowance'); + } +} + +pub fn assert_is_owner(caller: starknet::ContractAddress, owner: starknet::ContractAddress) { + assert(caller == owner, 'Not owner'); +} diff --git a/erc20_token/src/events.cairo b/erc20_token/src/events.cairo new file mode 100644 index 0000000..33782a3 --- /dev/null +++ b/erc20_token/src/events.cairo @@ -0,0 +1,15 @@ +use starknet::ContractAddress; + +#[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, +} diff --git a/erc20_token/src/interfaces.cairo b/erc20_token/src/interfaces.cairo new file mode 100644 index 0000000..addf268 --- /dev/null +++ b/erc20_token/src/interfaces.cairo @@ -0,0 +1,26 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC20 { + fn get_name(self: @TContractState) -> ByteArray; + fn get_symbol(self: @TContractState) -> ByteArray; + fn get_decimals(self: @TContractState) -> u8; + fn get_total_supply(self: @TContractState) -> u256; + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn allowance( + self: @TContractState, owner: ContractAddress, spender: ContractAddress, + ) -> u256; + fn get_owner(self: @TContractState) -> ContractAddress; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256); + fn transfer_from( + ref self: TContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256, + ); + fn approve(ref self: TContractState, spender: ContractAddress, amount: u256); + fn revoke(ref self: TContractState, spender: ContractAddress); + fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256); + fn burn(ref self: TContractState, from: ContractAddress, amount: u256); + fn set_max_limit(ref self: TContractState, new_limit: u256); +} diff --git a/erc20_token/src/lib.cairo b/erc20_token/src/lib.cairo new file mode 100644 index 0000000..604c613 --- /dev/null +++ b/erc20_token/src/lib.cairo @@ -0,0 +1,7 @@ +pub mod checks; +pub mod events; +pub mod interfaces; +pub mod storage; +pub mod token; + +pub use interfaces::IERC20; diff --git a/erc20_token/src/storage.cairo b/erc20_token/src/storage.cairo new file mode 100644 index 0000000..5838de0 --- /dev/null +++ b/erc20_token/src/storage.cairo @@ -0,0 +1 @@ +pub const MAX_LIMIT: u256 = 10_000; diff --git a/erc20_token/src/token.cairo b/erc20_token/src/token.cairo new file mode 100644 index 0000000..bb03689 --- /dev/null +++ b/erc20_token/src/token.cairo @@ -0,0 +1,176 @@ +#[starknet::contract] +pub mod ERC20Token { + use core::num::traits::Zero; + use starknet::{ContractAddress, get_caller_address}; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + + use crate::checks::{ + assert_enough_allowance, assert_enough_balance, assert_is_owner, assert_within_limit, + }; + use crate::events::{Approval, Transfer}; + use crate::storage::MAX_LIMIT; + + #[storage] + struct Storage { + owner: ContractAddress, + name: ByteArray, + symbol: ByteArray, + decimals: u8, + total_supply: u256, + max_limit: u256, + balances: Map::, + allowances: Map::<(ContractAddress, ContractAddress), u256>, + } + + fn zero_address() -> ContractAddress { + 0.try_into().unwrap() + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + Transfer: Transfer, + Approval: Approval, + } + + #[constructor] + fn constructor( + ref self: ContractState, + owner: ContractAddress, + recipient: ContractAddress, + name: ByteArray, + symbol: ByteArray, + decimals: u8, + initial_supply: u256, + ) { + self.owner.write(owner); + self.name.write(name); + self.symbol.write(symbol); + self.decimals.write(decimals); + self.max_limit.write(MAX_LIMIT); + self.total_supply.write(initial_supply); + self.balances.write(recipient, initial_supply); + self.emit( + Event::Transfer(Transfer { + from: zero_address(), to: recipient, value: initial_supply, + }), + ); + } + + #[abi(embed_v0)] + impl ERC20Impl of crate::interfaces::IERC20 { + fn get_name(self: @ContractState) -> ByteArray { + self.name.read() + } + + fn get_symbol(self: @ContractState) -> ByteArray { + self.symbol.read() + } + + fn get_decimals(self: @ContractState) -> u8 { + self.decimals.read() + } + + fn get_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 get_owner(self: @ContractState) -> ContractAddress { + self.owner.read() + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) { + assert_within_limit(amount, self.max_limit.read()); + + let sender = get_caller_address(); + assert(sender.is_non_zero(), 'From zero'); + assert(recipient.is_non_zero(), 'To zero'); + + let bal = self.balances.read(sender); + assert_enough_balance(bal, amount); + + self.balances.write(sender, bal - amount); + self.balances.write(recipient, self.balances.read(recipient) + amount); + self.emit(Event::Transfer(Transfer { from: sender, to: recipient, value: amount })); + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256, + ) { + assert_within_limit(amount, self.max_limit.read()); + + let caller = get_caller_address(); + let allowed = self.allowances.read((sender, caller)); + assert_enough_allowance(allowed, amount); + self.allowances.write((sender, caller), allowed - amount); + + let bal = self.balances.read(sender); + assert_enough_balance(bal, amount); + + self.balances.write(sender, bal - amount); + self.balances.write(recipient, self.balances.read(recipient) + amount); + self.emit(Event::Transfer(Transfer { from: sender, to: recipient, value: amount })); + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) { + assert(spender.is_non_zero(), 'Spender zero'); + + let owner = get_caller_address(); + self.allowances.write((owner, spender), amount); + self.emit(Event::Approval(Approval { owner, spender, value: amount })); + } + + fn revoke(ref self: ContractState, spender: ContractAddress) { + let owner = get_caller_address(); + self.allowances.write((owner, spender), Zero::zero()); + self.emit(Event::Approval(Approval { owner, spender, value: Zero::zero() })); + } + + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { + assert_is_owner(get_caller_address(), self.owner.read()); + assert(recipient.is_non_zero(), 'To zero'); + + self.total_supply.write(self.total_supply.read() + amount); + self.balances.write(recipient, self.balances.read(recipient) + amount); + self.emit( + Event::Transfer(Transfer { + from: zero_address(), to: recipient, value: amount, + }), + ); + } + + fn burn(ref self: ContractState, from: ContractAddress, amount: u256) { + assert_is_owner(get_caller_address(), self.owner.read()); + assert(from.is_non_zero(), 'From zero'); + + let bal = self.balances.read(from); + assert_enough_balance(bal, amount); + + self.balances.write(from, bal - amount); + self.total_supply.write(self.total_supply.read() - amount); + self.emit(Event::Transfer(Transfer { from, to: zero_address(), value: amount })); + } + + fn set_max_limit(ref self: ContractState, new_limit: u256) { + assert_is_owner(get_caller_address(), self.owner.read()); + self.max_limit.write(new_limit); + } + } +} diff --git a/erc20_token/tests/test_contract.cairo b/erc20_token/tests/test_contract.cairo new file mode 100644 index 0000000..8a1c5b6 --- /dev/null +++ b/erc20_token/tests/test_contract.cairo @@ -0,0 +1,186 @@ +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address, + stop_cheat_caller_address, +}; +use core::serde::Serde; +use starknet::ContractAddress; + +use erc20_token::interfaces::{IERC20Dispatcher, IERC20DispatcherTrait}; +use erc20_token::storage::MAX_LIMIT; + +fn admin() -> ContractAddress { + 'admin'.try_into().unwrap() +} + +fn user() -> ContractAddress { + 'user'.try_into().unwrap() +} + +fn spender() -> ContractAddress { + 'spender'.try_into().unwrap() +} + +fn deploy( + admin_addr: ContractAddress, + recipient: ContractAddress, + initial_supply: u256, +) -> ContractAddress { + let contract = declare("ERC20Token").unwrap().contract_class(); + let name: ByteArray = "TestToken"; + let symbol: ByteArray = "TTK"; + let mut calldata = array![]; + admin_addr.serialize(ref calldata); + recipient.serialize(ref calldata); + name.serialize(ref calldata); + symbol.serialize(ref calldata); + 18_u8.serialize(ref calldata); + initial_supply.serialize(ref calldata); + let (address, _) = contract.deploy(@calldata).unwrap(); + address +} + +#[test] +fn constructor_sets_state() { + let token = deploy(admin(), user(), 50_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + assert(dispatcher.get_name() == "TestToken", 'name'); + assert(dispatcher.get_symbol() == "TTK", 'symbol'); + assert(dispatcher.get_decimals() == 18, 'decimals'); + assert(dispatcher.get_total_supply() == 50_000, 'supply'); + assert(dispatcher.get_owner() == admin(), 'owner'); + assert(dispatcher.balance_of(user()) == 50_000, 'recipient balance'); + assert(dispatcher.balance_of(admin()) == 0, 'admin balance'); +} + +#[test] +fn transfer_moves_tokens() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.transfer(admin(), 1000); + stop_cheat_caller_address(token); + + assert(dispatcher.balance_of(user()) == 9000, 'sender'); + assert(dispatcher.balance_of(admin()) == 1000, 'recipient'); +} + +#[test] +#[should_panic(expected: ('Over limit',))] +fn transfer_over_max_limit_panics() { + let token = deploy(admin(), user(), 50_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.transfer(admin(), MAX_LIMIT + 1); + stop_cheat_caller_address(token); +} + +#[test] +#[should_panic(expected: ('Low balance',))] +fn transfer_insufficient_balance_panics() { + let token = deploy(admin(), user(), 100); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.transfer(admin(), 101); + stop_cheat_caller_address(token); +} + +#[test] +fn approve_and_transfer_from() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.approve(spender(), 3000); + stop_cheat_caller_address(token); + + assert(dispatcher.allowance(user(), spender()) == 3000, 'allowance'); + + start_cheat_caller_address(token, spender()); + dispatcher.transfer_from(user(), admin(), 2000); + stop_cheat_caller_address(token); + + assert(dispatcher.balance_of(user()) == 8000, 'owner balance'); + assert(dispatcher.balance_of(admin()) == 2000, 'recipient balance'); + assert(dispatcher.allowance(user(), spender()) == 1000, 'remaining allowance'); +} + +#[test] +fn revoke_clears_allowance() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.approve(spender(), 500); + dispatcher.revoke(spender()); + stop_cheat_caller_address(token); + + assert(dispatcher.allowance(user(), spender()) == 0, 'revoked'); +} + +#[test] +fn owner_can_mint_from_zero_address() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, admin()); + dispatcher.mint(admin(), 2_500); + stop_cheat_caller_address(token); + + assert(dispatcher.balance_of(admin()) == 2_500, 'minted balance'); + assert(dispatcher.get_total_supply() == 12_500, 'minted supply'); +} + +#[test] +fn owner_burn_reduces_balance_and_supply() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, admin()); + dispatcher.burn(user(), 2500); + stop_cheat_caller_address(token); + + assert(dispatcher.balance_of(user()) == 7500, 'balance'); + assert(dispatcher.get_total_supply() == 7500, 'supply'); +} + +#[test] +fn owner_can_update_max_limit() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, admin()); + dispatcher.set_max_limit(500); + stop_cheat_caller_address(token); + + start_cheat_caller_address(token, user()); + dispatcher.transfer(admin(), 500); + stop_cheat_caller_address(token); + + assert(dispatcher.balance_of(admin()) == 500, 'transfer at new limit'); +} + +#[test] +#[should_panic(expected: ('Not owner',))] +fn non_owner_cannot_burn() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.burn(user(), 1); + stop_cheat_caller_address(token); +} + +#[test] +#[should_panic(expected: ('Not owner',))] +fn non_owner_cannot_mint() { + let token = deploy(admin(), user(), 10_000); + let dispatcher = IERC20Dispatcher { contract_address: token }; + + start_cheat_caller_address(token, user()); + dispatcher.mint(user(), 1); + stop_cheat_caller_address(token); +} diff --git a/starknet-agentic b/starknet-agentic new file mode 160000 index 0000000..dd8f4e8 --- /dev/null +++ b/starknet-agentic @@ -0,0 +1 @@ +Subproject commit dd8f4e8b2143f2787e38a0946e1c4fe3bf6137c5