From 75c83403b7a2ab8078094a14feda4758381e2bb5 Mon Sep 17 00:00:00 2001 From: Mac-5 Date: Fri, 22 May 2026 09:37:36 +0100 Subject: [PATCH 1/2] feat: Implemented a fully functional restricted erc20 token --- .gitignore | 4 + Scarb.lock | 24 ++ Scarb.toml | 52 +++ snfoundry.toml | 11 + src/lib.cairo | 324 +++++++++++++++++ tests/test_contract.cairo | 744 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 1159 insertions(+) create mode 100644 Scarb.lock create mode 100644 Scarb.toml create mode 100644 snfoundry.toml create mode 100644 src/lib.cairo create mode 100644 tests/test_contract.cairo diff --git a/.gitignore b/.gitignore index eb5a316..4096f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/Scarb.lock b/Scarb.lock new file mode 100644 index 0000000..cf83017 --- /dev/null +++ b/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "erc_contract" +version = "0.1.0" +dependencies = [ + "snforge_std", +] + +[[package]] +name = "snforge_scarb_plugin" +version = "0.60.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:924358bf316e502923f6733b50e239ea37585a05dc24c5fc8dd9e45f88cf7339" + +[[package]] +name = "snforge_std" +version = "0.60.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:32e6baabec4f9af21089bc7ca685ffea5e4164497340ecbdb99314e568029195" +dependencies = [ + "snforge_scarb_plugin", +] diff --git a/Scarb.toml b/Scarb.toml new file mode 100644 index 0000000..74dd726 --- /dev/null +++ b/Scarb.toml @@ -0,0 +1,52 @@ +[package] +name = "erc_contract" +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.18.0" + +[dev-dependencies] +snforge_std = "0.60.0" +assert_macros = "2.18.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/snfoundry.toml b/snfoundry.toml new file mode 100644 index 0000000..686c2ab --- /dev/null +++ b/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/src/lib.cairo b/src/lib.cairo new file mode 100644 index 0000000..0f1eb75 --- /dev/null +++ b/src/lib.cairo @@ -0,0 +1,324 @@ +#[starknet::interface] +pub trait IRestrictedToken { + // ── reads + 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 spending_limit(self: @TContractState) -> u256; + fn is_revoked(self: @TContractState) -> bool; + + // ── writes + // ───────────────────────────────────────────────────────────── + 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; + fn decrease_allowance_by_spender( + ref self: TContractState, owner: starknet::ContractAddress, subtracted_value: u256, + ) -> bool; + + // ── admin + // ────────────────────────────────────────────────────────────── + fn set_spending_limit(ref self: TContractState, new_limit: u256); + fn revoke(ref self: TContractState); + fn restore(ref self: TContractState); + fn burn(ref self: TContractState, account: starknet::ContractAddress, amount: u256); +} +#[starknet::contract] +pub mod RestrictedToken { + use core::num::traits::Zero; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_caller_address}; + + const MAX_LIMIT: u256 = 10_000; + + //storage + #[storage] + pub struct Storage { + name: ByteArray, + symbol: ByteArray, + decimals: u8, + total_supply: u256, + balances: Map, + allowances: Map<(ContractAddress, ContractAddress), u256>, + admin: ContractAddress, + spending_limit: u256, + is_revoked: bool, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Transfer: Transfer, + Approval: Approval, + SpendingLimitUpdated: SpendingLimitUpdated, + Revoked: Revoked, + Restored: Restored, + Burn: Burn, + } + + #[derive(Drop, starknet::Event)] + pub struct Transfer { + #[key] + pub from: ContractAddress, + #[key] + pub to: ContractAddress, + pub amount: u256, + } + #[derive(Drop, starknet::Event)] + pub struct Approval { + #[key] + pub owner: ContractAddress, + #[key] + pub spender: ContractAddress, + pub amount: u256, + } + #[derive(Drop, starknet::Event)] + pub struct SpendingLimitUpdated { + pub old_limit: u256, + pub new_limit: u256, + } + #[derive(Drop, starknet::Event)] + pub struct Revoked { + pub by: ContractAddress, + } + #[derive(Drop, starknet::Event)] + pub struct Restored { + pub by: ContractAddress, + } + #[derive(Drop, starknet::Event)] + pub struct Burn { + #[key] + pub account: ContractAddress, + pub amount: u256, + } + + pub mod Errors { + pub const NOT_ADMIN: felt252 = 'caller is not admin'; + pub const REVOKED: felt252 = 'transfers revoked'; + pub const EXCEEDS_LIMIT: felt252 = 'amount exceeds limit'; + pub const EXCEEDS_MAX_LIMIT: felt252 = 'limit exceeds MAX_LIMIT'; + pub const INSUFFICIENT_BALANCE: felt252 = 'insufficient balance'; + pub const INSUFFICIENT_ALLOWANCE: felt252 = 'insufficient allowance'; + pub const ZERO_ADDRESS: felt252 = 'zero address not allowed'; + pub const ZERO_AMOUNT: felt252 = 'amount cannot be zero'; + pub const ALREADY_REVOKED: felt252 = 'already revoked'; + pub const NOT_REVOKED: felt252 = 'not revoked'; + pub const SELF_TRANSFER: felt252 = 'cannot transfer to self'; + pub const SELF_APPROVAL: felt252 = 'cannot approve self'; + pub const SAME_LIMIT: felt252 = 'limit already set'; + pub const RESET_ALLOWANCE_FIRST: felt252 = 'reset allowance first'; + } + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + admin: ContractAddress, + initial_supply: u256, + ) { + assert(!admin.is_zero(), Errors::ZERO_ADDRESS); + self.name.write(name); + self.symbol.write(symbol); + self.decimals.write(18); + self.admin.write(admin); + self.spending_limit.write(MAX_LIMIT); + self.is_revoked.write(false); + + self.total_supply.write(0); // Initialized to 0, then we mint + self._mint(admin, initial_supply); + } + #[generate_trait] + impl InternalImpl of InternalTrait { + fn assert_only_admin(self: @ContractState) { + assert(get_caller_address() == self.admin.read(), Errors::NOT_ADMIN); + } + + fn assert_not_revoked(self: @ContractState) { + assert(!self.is_revoked.read(), Errors::REVOKED); + } + + fn assert_within_limit(self: @ContractState, amount: u256) { + assert(amount <= self.spending_limit.read(), Errors::EXCEEDS_LIMIT); + } + + fn validate_transfer(self: @ContractState, recipient: ContractAddress, amount: u256) { + assert(!recipient.is_zero(), Errors::ZERO_ADDRESS); + assert(amount > 0, Errors::ZERO_AMOUNT); + self.assert_not_revoked(); + self.assert_within_limit(amount); + } + + fn execute_transfer( + ref self: ContractState, from: ContractAddress, to: ContractAddress, amount: u256, + ) { + assert(from != to, Errors::SELF_TRANSFER); + let from_balance = self.balances.read(from); + assert(from_balance >= amount, Errors::INSUFFICIENT_BALANCE); + + self.balances.write(from, from_balance - amount); + self.balances.write(to, self.balances.read(to) + amount); + self.emit(Transfer { from, to, amount }); + } + + fn _mint(ref self: ContractState, to: ContractAddress, amount: u256) { + assert(!to.is_zero(), Errors::ZERO_ADDRESS); + self.total_supply.write(self.total_supply.read() + amount); + self.balances.write(to, self.balances.read(to) + amount); + self.emit(Transfer { from: Zero::zero(), to, amount }); + } + + fn _burn(ref self: ContractState, account: ContractAddress, amount: u256) { + assert(!account.is_zero(), Errors::ZERO_ADDRESS); + let balance = self.balances.read(account); + assert(balance >= amount, Errors::INSUFFICIENT_BALANCE); + + self.balances.write(account, balance - amount); + // Explicitly track burned tokens in the zero address balance + self.balances.write(Zero::zero(), self.balances.read(Zero::zero()) + amount); + self.total_supply.write(self.total_supply.read() - amount); + self.emit(Transfer { from: account, to: Zero::zero(), amount }); + } + } + + + #[abi(embed_v0)] + impl RestrictedTokenImpl of super::IRestrictedToken { + // reads + 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 spending_limit(self: @ContractState) -> u256 { + self.spending_limit.read() + } + fn is_revoked(self: @ContractState) -> bool { + self.is_revoked.read() + } + + // transfer + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + self.validate_transfer(recipient, amount); + self.execute_transfer(get_caller_address(), recipient, amount); + true + } + + // transfer_from + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256, + ) -> bool { + assert(!sender.is_zero(), Errors::ZERO_ADDRESS); + self.validate_transfer(recipient, amount); + + let caller = get_caller_address(); + let current_allowance = self.allowances.read((sender, caller)); + assert(current_allowance >= amount, Errors::INSUFFICIENT_ALLOWANCE); + self.allowances.write((sender, caller), current_allowance - amount); + + self.execute_transfer(sender, recipient, amount); + true + } + + // approve + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + assert(!spender.is_zero(), Errors::ZERO_ADDRESS); + let owner = get_caller_address(); + assert(!owner.is_zero(), Errors::ZERO_ADDRESS); + assert(owner != spender, Errors::SELF_APPROVAL); + + // Prevent the multiple withdrawal attack (approve front-running bug) + // Enforce that allowances must be reset to zero first before changing. + let current_allowance = self.allowances.read((owner, spender)); + assert(amount == 0 || current_allowance == 0, Errors::RESET_ALLOWANCE_FIRST); + + self.allowances.write((owner, spender), amount); + self.emit(Approval { owner, spender, amount }); + true + } + + // decrease_allowance_by_spender + fn decrease_allowance_by_spender( + ref self: ContractState, owner: ContractAddress, subtracted_value: u256, + ) -> bool { + let spender = get_caller_address(); + assert(!spender.is_zero(), Errors::ZERO_ADDRESS); + assert(!owner.is_zero(), Errors::ZERO_ADDRESS); + + let current_allowance = self.allowances.read((owner, spender)); + assert(current_allowance >= subtracted_value, Errors::INSUFFICIENT_ALLOWANCE); + + let new_allowance = current_allowance - subtracted_value; + self.allowances.write((owner, spender), new_allowance); + self.emit(Approval { owner, spender, amount: new_allowance }); + true + } + + // ── admin functions + // ─────────────────────────────────────────────── + + fn set_spending_limit(ref self: ContractState, new_limit: u256) { + self.assert_only_admin(); + assert(new_limit <= MAX_LIMIT, Errors::EXCEEDS_MAX_LIMIT); + assert(new_limit > 0, Errors::ZERO_AMOUNT); + + let old_limit = self.spending_limit.read(); + assert(old_limit != new_limit, Errors::SAME_LIMIT); + self.spending_limit.write(new_limit); + self.emit(SpendingLimitUpdated { old_limit, new_limit }); + } + + fn revoke(ref self: ContractState) { + self.assert_only_admin(); + assert(!self.is_revoked.read(), Errors::ALREADY_REVOKED); + self.is_revoked.write(true); + self.emit(Revoked { by: get_caller_address() }); + } + + fn restore(ref self: ContractState) { + self.assert_only_admin(); + assert(self.is_revoked.read(), Errors::NOT_REVOKED); + self.is_revoked.write(false); + self.emit(Restored { by: get_caller_address() }); + } + + fn burn(ref self: ContractState, account: ContractAddress, amount: u256) { + self.assert_only_admin(); + assert(amount > 0, Errors::ZERO_AMOUNT); + self._burn(account, amount); + self.emit(Burn { account, amount }); + } + } +} diff --git a/tests/test_contract.cairo b/tests/test_contract.cairo new file mode 100644 index 0000000..dccc7ba --- /dev/null +++ b/tests/test_contract.cairo @@ -0,0 +1,744 @@ +use core::num::traits::Zero; +use erc_contract::RestrictedToken::{ + Approval, Burn, Event, Restored, Revoked, SpendingLimitUpdated, Transfer, +}; +use erc_contract::{IRestrictedTokenDispatcher, IRestrictedTokenDispatcherTrait}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, spy_events, start_cheat_caller_address, + stop_cheat_caller_address, + EventSpyAssertionsTrait, +}; +use starknet::ContractAddress; + + +//constants +const INIITAL_SUPPLY: u256 = 1_000_000; +const MAX_LIMIT: u256 = 10_000; + +fn ADMIN() -> ContractAddress { + 'ADMIN'.try_into().unwrap() +} +fn USER1() -> ContractAddress { + 'USER1'.try_into().unwrap() +} +fn USER2() -> ContractAddress { + 'USER2'.try_into().unwrap() +} + +fn __deploy__() -> IRestrictedTokenDispatcher { + let contract_class = declare("RestrictedToken").expect('failed to declare').contract_class(); + let name: ByteArray = "TestToken"; + let symbol: ByteArray = "TTK"; + let mut calldata: Array = array![]; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + ADMIN().serialize(ref calldata); + INIITAL_SUPPLY.serialize(ref calldata); + + let (contract_address, _) = contract_class.deploy(@calldata).expect('failed + to deploy'); + + IRestrictedTokenDispatcher { contract_address } +} + +//test deployment +#[test] +fn test_deploy_sets_name() { + let token = __deploy__(); + assert(token.name() == "TestToken", 'wrong name'); +} +#[test] +fn test_deploy_sets_symbol() { + let token = __deploy__(); + assert(token.symbol() == "TTK", 'wrong symbol'); +} + +#[test] +fn test_deploy_sets_decimals() { + let token = __deploy__(); + assert(token.decimals() == 18, 'wrong decimals'); +} + +#[test] +fn test_deploy_sets_total_supply() { + let token = __deploy__(); + assert(token.total_supply() == INIITAL_SUPPLY, 'wrong total supply'); +} + +#[test] +fn test_deploy_mints_supply_to_admin() { + let token = __deploy__(); + assert(token.balance_of(ADMIN()) == INIITAL_SUPPLY, 'admin should hold supply'); +} + +#[test] +fn test_deploy_sets_spending_limit_to_max() { + let token = __deploy__(); + assert(token.spending_limit() == MAX_LIMIT, 'limit should be MAX_LIMIT'); +} + +#[test] +fn test_deploy_not_revoked() { + let token = __deploy__(); + assert(!token.is_revoked(), 'should not be revoked'); +} + +#[test] +fn test_deploy_emits_mint_transfer_event() { + let contract_class = declare("RestrictedToken").expect('Failed to declare').contract_class(); + + let mut calldata: Array = array![]; + let name: ByteArray = "TestToken"; + let symbol: ByteArray = "TTK"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + ADMIN().serialize(ref calldata); + INIITAL_SUPPLY.serialize(ref calldata); + + let mut spy = spy_events(); + let (contract_address, _) = contract_class.deploy(@calldata).expect('failed to deploy'); + + spy + .assert_emitted( + @array![ + ( + contract_address, + Event::Transfer( + Transfer { from: Zero::zero(), to: ADMIN(), amount: INIITAL_SUPPLY }, + ), + ), + ], + ); +} + +#[test] +fn test_transfer_succeeds_within_limit() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + let result = token.transfer(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + assert(result, 'transfer should return true'); + assert(token.balance_of(USER1()) == 500, 'user1 should have 500'); + assert(token.balance_of(ADMIN()) == INIITAL_SUPPLY - 500, 'admin balance wrong'); +} + +#[test] +fn test_transfer_at_exact_limit() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + let result = token.transfer(USER1(), MAX_LIMIT); + stop_cheat_caller_address(token.contract_address); + + assert(result, 'transfer at limit should pass'); + assert(token.balance_of(USER1()) == MAX_LIMIT, 'user1 balance wrong'); +} + +#[test] +fn test_transfer_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.transfer(USER1(), 100); + stop_cheat_caller_address(token.contract_address); + + spy + .assert_emitted( + @array![ + ( + token.contract_address, + Event::Transfer(Transfer { from: ADMIN(), to: USER1(), amount: 100 }), + ), + ], + ); +} + +#[test] +#[should_panic(expected: 'amount exceeds limit')] +fn test_transfer_fails_above_limit() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.transfer(USER1(), MAX_LIMIT + 1); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'insufficient balance')] +fn test_transfer_fails_insufficient_balance() { + let token = __deploy__(); + + // USER1 has no tokens + start_cheat_caller_address(token.contract_address, USER1()); + token.transfer(USER2(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'zero address not allowed')] +fn test_transfer_fails_to_zero_address() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.transfer(Zero::zero(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'amount cannot be zero')] +fn test_transfer_fails_zero_amount() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.transfer(USER1(), 0); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'transfers revoked')] +fn test_transfer_fails_when_revoked() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + token.transfer(USER1(), 100); + stop_cheat_caller_address(token.contract_address); +} + +// ─── approve & transfer_from +// ─────────────────────────────────────────────── + +#[test] +fn test_approve_sets_allowance() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + assert(token.allowance(ADMIN(), USER1()) == 500, 'allowance should be 500'); +} + +#[test] +fn test_approve_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + spy + .assert_emitted( + @array![ + ( + token.contract_address, + Event::Approval(Approval { owner: ADMIN(), spender: USER1(), amount: 500 }), + ), + ], + ); +} + +#[test] +fn test_transfer_from_succeeds_with_allowance() { + let token = __deploy__(); + + // admin approves USER1 to spend 500 + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + // USER1 spends on behalf of ADMIN → USER2 + start_cheat_caller_address(token.contract_address, USER1()); + let result = token.transfer_from(ADMIN(), USER2(), 300); + stop_cheat_caller_address(token.contract_address); + + assert(result, 'transfer_from should succeed'); + assert(token.balance_of(USER2()) == 300, 'USER2 should have 300'); + assert(token.allowance(ADMIN(), USER1()) == 200, 'allowance should decrease'); +} + +#[test] +#[should_panic(expected: 'insufficient allowance')] +fn test_transfer_from_fails_without_allowance() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, USER1()); + token.transfer_from(ADMIN(), USER2(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'zero address not allowed')] +fn test_transfer_from_fails_zero_sender() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, USER1()); + token.transfer_from(Zero::zero(), USER2(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'transfers revoked')] +fn test_transfer_from_fails_when_revoked() { + let token = __deploy__(); + + // Admin approves USER1 + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + token.revoke(); + stop_cheat_caller_address(token.contract_address); + + // transfer_from should fail because revoked + start_cheat_caller_address(token.contract_address, USER1()); + token.transfer_from(ADMIN(), USER2(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'amount exceeds limit')] +fn test_transfer_from_fails_above_limit() { + let token = __deploy__(); + + // Admin approves USER1 for a large amount + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), MAX_LIMIT + 1); + stop_cheat_caller_address(token.contract_address); + + // transfer_from should fail because amount > spending limit + start_cheat_caller_address(token.contract_address, USER1()); + token.transfer_from(ADMIN(), USER2(), MAX_LIMIT + 1); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'zero address not allowed')] +fn test_approve_fails_zero_spender() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(Zero::zero(), 500); + stop_cheat_caller_address(token.contract_address); +} + +// ─── set_spending_limit +// ──────────────────────────────────────────────────── + +#[test] +fn test_admin_can_lower_spending_limit() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.set_spending_limit(5000); + stop_cheat_caller_address(token.contract_address); + + assert(token.spending_limit() == 5000, 'limit should be 5000'); +} + +#[test] +fn test_set_spending_limit_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.set_spending_limit(5000); + stop_cheat_caller_address(token.contract_address); + + spy + .assert_emitted( + @array![ + ( + token.contract_address, + Event::SpendingLimitUpdated( + SpendingLimitUpdated { old_limit: MAX_LIMIT, new_limit: 5000 }, + ), + ), + ], + ); +} + +#[test] +#[should_panic(expected: 'limit exceeds MAX_LIMIT')] +fn test_set_spending_limit_cannot_exceed_max() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.set_spending_limit(MAX_LIMIT + 1); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'amount cannot be zero')] +fn test_set_spending_limit_cannot_be_zero() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.set_spending_limit(0); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'caller is not admin')] +fn test_non_admin_cannot_set_limit() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, USER1()); + token.set_spending_limit(5000); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'amount exceeds limit')] +fn test_transfer_fails_after_limit_lowered() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.set_spending_limit(100); + // this should panic since 500 > new limit of 100 + token.transfer(USER1(), 500); + stop_cheat_caller_address(token.contract_address); +} + +// ─── revoke / restore +// ────────────────────────────────────────────────────── + +#[test] +fn test_admin_can_revoke() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + stop_cheat_caller_address(token.contract_address); + + assert(token.is_revoked(), 'should be revoked'); +} + +#[test] +fn test_revoke_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + stop_cheat_caller_address(token.contract_address); + + spy.assert_emitted(@array![(token.contract_address, Event::Revoked(Revoked { by: ADMIN() }))]); +} + +#[test] +#[should_panic(expected: 'already revoked')] +fn test_revoke_twice_panics() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + token.revoke(); // second call should panic + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'caller is not admin')] +fn test_non_admin_cannot_revoke() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, USER1()); + token.revoke(); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +fn test_admin_can_restore() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + token.restore(); + stop_cheat_caller_address(token.contract_address); + + assert(!token.is_revoked(), 'should not be revoked'); +} + +#[test] +fn test_restore_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + token.restore(); + stop_cheat_caller_address(token.contract_address); + + spy + .assert_emitted( + @array![(token.contract_address, Event::Restored(Restored { by: ADMIN() }))], + ); +} + +#[test] +#[should_panic(expected: 'not revoked')] +fn test_restore_without_revoke_panics() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.restore(); // not revoked yet, should panic + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'caller is not admin')] +fn test_non_admin_cannot_restore() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + stop_cheat_caller_address(token.contract_address); + + start_cheat_caller_address(token.contract_address, USER1()); + token.restore(); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +fn test_transfer_works_after_restore() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + token.restore(); + let result = token.transfer(USER1(), 100); + stop_cheat_caller_address(token.contract_address); + + assert(result, 'transfer should work'); + assert(token.balance_of(USER1()) == 100, 'USER1 should have 100'); +} + +// ─── burn (admin-only) +// ────────────────────────────────────────────────────── + +#[test] +fn test_admin_can_burn_tokens() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.burn(ADMIN(), 1000); + stop_cheat_caller_address(token.contract_address); + + assert(token.balance_of(ADMIN()) == INIITAL_SUPPLY - 1000, 'balance should decrease'); + assert(token.total_supply() == INIITAL_SUPPLY - 1000, 'total supply should decrease'); +} + +#[test] +fn test_burn_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.burn(ADMIN(), 500); + stop_cheat_caller_address(token.contract_address); + + // ERC-20 standard: burn emits Transfer to zero address + spy + .assert_emitted( + @array![ + ( + token.contract_address, + Event::Transfer( + Transfer { from: ADMIN(), to: Zero::zero(), amount: 500 }, + ), + ), + ], + ); + + // Also emits the custom Burn event + spy + .assert_emitted( + @array![ + ( + token.contract_address, + Event::Burn(Burn { account: ADMIN(), amount: 500 }), + ), + ], + ); +} + +#[test] +fn test_admin_can_burn_other_account() { + let token = __deploy__(); + + // Give USER1 some tokens first + start_cheat_caller_address(token.contract_address, ADMIN()); + token.transfer(USER1(), 5000); + token.burn(USER1(), 2000); + stop_cheat_caller_address(token.contract_address); + + assert(token.balance_of(USER1()) == 3000, 'USER1 should have 3000'); + assert(token.total_supply() == INIITAL_SUPPLY - 2000, 'supply should decrease by 2000'); +} + +#[test] +#[should_panic(expected: 'caller is not admin')] +fn test_non_admin_cannot_burn() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, USER1()); + token.burn(ADMIN(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'zero address not allowed')] +fn test_burn_fails_zero_address() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.burn(Zero::zero(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'amount cannot be zero')] +fn test_burn_fails_zero_amount() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.burn(ADMIN(), 0); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'insufficient balance')] +fn test_burn_fails_insufficient_balance() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.burn(ADMIN(), INIITAL_SUPPLY + 1); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +fn test_burn_works_while_revoked() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.revoke(); + // Burn is an admin privilege — works even when transfers are revoked + token.burn(ADMIN(), 1000); + stop_cheat_caller_address(token.contract_address); + + assert(token.balance_of(ADMIN()) == INIITAL_SUPPLY - 1000, 'burn should work when revoked'); + assert(token.total_supply() == INIITAL_SUPPLY - 1000, 'supply should decrease'); +} + +// ─── additional validation tests +// ────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected: 'cannot transfer to self')] +fn test_transfer_to_self_fails() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.transfer(ADMIN(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'cannot transfer to self')] +fn test_transfer_from_to_self_fails() { + let token = __deploy__(); + + // Admin approves USER1 to spend on their behalf + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + // USER1 tries to transfer from ADMIN back to ADMIN + start_cheat_caller_address(token.contract_address, USER1()); + token.transfer_from(ADMIN(), ADMIN(), 100); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'cannot approve self')] +fn test_approve_self_fails() { + let token = __deploy__(); + + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(ADMIN(), 500); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +#[should_panic(expected: 'limit already set')] +fn test_set_spending_limit_same_value_fails() { + let token = __deploy__(); + + // MAX_LIMIT is the default; setting it again should fail + start_cheat_caller_address(token.contract_address, ADMIN()); + token.set_spending_limit(MAX_LIMIT); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +fn test_decrease_allowance_by_spender_works() { + let token = __deploy__(); + + // ADMIN approves USER1 for 500 + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + // USER1 decides to decrease their own allowance by 200 + start_cheat_caller_address(token.contract_address, USER1()); + token.decrease_allowance_by_spender(ADMIN(), 200); + stop_cheat_caller_address(token.contract_address); + + // The allowance should now be 300 + assert(token.allowance(ADMIN(), USER1()) == 300, 'allowance should be 300'); +} + +#[test] +#[should_panic(expected: 'insufficient allowance')] +fn test_decrease_allowance_fails_insufficient() { + let token = __deploy__(); + + // ADMIN approves USER1 for 500 + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + // USER1 tries to decrease their allowance by 600 (more than they have) + start_cheat_caller_address(token.contract_address, USER1()); + token.decrease_allowance_by_spender(ADMIN(), 600); + stop_cheat_caller_address(token.contract_address); +} + +#[test] +fn test_decrease_allowance_emits_event() { + let token = __deploy__(); + let mut spy = spy_events(); + + // ADMIN approves USER1 for 500 + start_cheat_caller_address(token.contract_address, ADMIN()); + token.approve(USER1(), 500); + stop_cheat_caller_address(token.contract_address); + + // USER1 decreases allowance by 200 + start_cheat_caller_address(token.contract_address, USER1()); + token.decrease_allowance_by_spender(ADMIN(), 200); + stop_cheat_caller_address(token.contract_address); + + // Expect Approval event with the NEW allowance (300) + spy + .assert_emitted( + @array![ + ( + token.contract_address, + Event::Approval(Approval { owner: ADMIN(), spender: USER1(), amount: 300 }), + ), + ], + ); +} From dd27e111b11735c98a7c81e58b042c5e5bafdd71 Mon Sep 17 00:00:00 2001 From: Mac-5 Date: Fri, 22 May 2026 09:46:53 +0100 Subject: [PATCH 2/2] Initial commit: Starknet transfer agent --- .gitignore | 3 + package.json | 19 +++++ src/deployAccount.ts | 111 +++++++++++++++++++++++++++ src/deployArgent.ts | 94 +++++++++++++++++++++++ src/index.ts | 12 +++ src/test.ts | 23 ++++++ src/transferAgent.ts | 179 +++++++++++++++++++++++++++++++++++++++++++ test_balance.js | 30 ++++++++ test_balance2.js | 28 +++++++ tsconfig.json | 10 +++ 10 files changed, 509 insertions(+) create mode 100644 package.json create mode 100644 src/deployAccount.ts create mode 100644 src/deployArgent.ts create mode 100644 src/index.ts create mode 100644 src/test.ts create mode 100644 src/transferAgent.ts create mode 100644 test_balance.js create mode 100644 test_balance2.js create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 4096f8b..c34a872 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ target snfoundry_trace/ coverage/ profile/ +.env +node_modules/ +dist/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..4940e5a --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@starknet-agentic/transfer-agent", + "version": "1.0.0", + "description": "Autonomous transfer agent for Starknet", + "main": "dist/index.js", + "scripts": { + "start": "ts-node src/index.ts", + "build": "tsc" + }, + "dependencies": { + "dotenv": "^16.4.5", + "starknet": "^9.4.2" + }, + "devDependencies": { + "@types/node": "^20.12.7", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" + } +} diff --git a/src/deployAccount.ts b/src/deployAccount.ts new file mode 100644 index 0000000..7111ba7 --- /dev/null +++ b/src/deployAccount.ts @@ -0,0 +1,111 @@ +import { RpcProvider, Account, stark, ec, hash, CallData, constants } from "starknet"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +// ─── DEPLOY ACCOUNT ON SEPOLIA ─────────────────────────────────────────────── +// This script deploys an OpenZeppelin account contract using your private key. +// It uses STRK for gas fees (V3 transaction). + +const PROVIDER = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL! }); + +// OpenZeppelin Account class hash on Sepolia +// This is the standard OZ Account v0.14.0 class hash +const OZ_ACCOUNT_CLASS_HASH = "0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f"; + +async function deployAccount() { + const privateKey = process.env.PRIVATE_KEY!; + + // Derive public key from private key + const publicKey = ec.starkCurve.getStarkKey(privateKey); + console.log(`🔑 Public Key: ${publicKey}`); + + // Compute the expected account address + const constructorCalldata = CallData.compile({ public_key: publicKey }); + const computedAddress = hash.calculateContractAddressFromHash( + publicKey, // salt + OZ_ACCOUNT_CLASS_HASH, + constructorCalldata, + 0 // deployer address (0 = not deployed via factory) + ); + console.log(`📍 Computed Address: ${computedAddress}`); + console.log(`📍 Your .env Address: ${process.env.WALLET_ADDRESS}`); + + // Check if the computed address matches your .env address + const envAddr = BigInt(process.env.WALLET_ADDRESS!); + const compAddr = BigInt(computedAddress); + + if (envAddr !== compAddr) { + console.log(`\n⚠️ Address mismatch! Your wallet was likely created with a different class hash.`); + console.log(` Computed: ${computedAddress}`); + console.log(` .env: ${process.env.WALLET_ADDRESS}`); + console.log(`\n Trying alternative class hashes...`); + + // Try common alternative class hashes + const alternatives = [ + { name: "OZ Account v0.8.1", hash: "0x05400e90f7b74d3fefba034769e661802e4f8f2ab0efbb1a0bd1dc3b82b48e5e" }, + { name: "OZ Account v0.9.0", hash: "0x01a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003" }, + { name: "OZ Account v0.11.0", hash: "0x04c6d6cf894f8bc96bb9c525e6853e5483177841f7388f74a46cfda6f028c755" }, + { name: "OZ Account v0.13.0", hash: "0x00e2eb8f5672af4e6a4e8a8f1b44989685e668489b0a25437733756c5a34a1d6" }, + { name: "Argent Account", hash: "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f" }, + { name: "Braavos Account", hash: "0x00816dd0297efc55dc1e7559020a3a825e81ef734b558f03c83325d4da7e6253" }, + ]; + + for (const alt of alternatives) { + const altAddr = hash.calculateContractAddressFromHash( + publicKey, + alt.hash, + CallData.compile({ public_key: publicKey }), + 0 + ); + if (BigInt(altAddr) === envAddr) { + console.log(`\n✅ Match found: ${alt.name} (${alt.hash})`); + console.log(` Use this class hash to deploy.`); + + // Deploy with this class hash + await doDeployAccount(privateKey, publicKey, alt.hash, altAddr); + return; + } + } + + console.log(`\n❌ Could not match your address to any known class hash.`); + console.log(` Your account may have been created with a custom salt or class hash.`); + console.log(` Please check which wallet (ArgentX, Braavos, etc.) generated this address.`); + return; + } + + await doDeployAccount(privateKey, publicKey, OZ_ACCOUNT_CLASS_HASH, computedAddress); +} + +async function doDeployAccount(privateKey: string, publicKey: string, classHash: string, address: string) { + console.log(`\n🚀 Deploying account at ${address}...`); + + const account = new Account({ provider: PROVIDER, address, signer: privateKey }); + + const constructorCalldata = CallData.compile({ public_key: publicKey }); + + try { + const deployResponse = await account.deployAccount({ + classHash: classHash, + constructorCalldata: constructorCalldata, + addressSalt: publicKey, + }); + + console.log(`📝 Deploy TX Hash: ${deployResponse.transaction_hash}`); + console.log(`⏳ Waiting for confirmation...`); + + await PROVIDER.waitForTransaction(deployResponse.transaction_hash); + console.log(`✅ Account deployed successfully!`); + console.log(` Address: ${deployResponse.contract_address}`); + } catch (err: any) { + console.error(`❌ Deployment failed: ${err.message}`); + + if (err.message.includes("Contract not found") || err.message.includes("Invalid block")) { + console.log(`\n💡 Tip: The account may need ETH or STRK for deployment gas.`); + console.log(` Your balance: 800 STRK, 0 ETH`); + console.log(` Try getting some Sepolia ETH from a faucet first.`); + } + } +} + +deployAccount().catch(console.error); diff --git a/src/deployArgent.ts b/src/deployArgent.ts new file mode 100644 index 0000000..a259931 --- /dev/null +++ b/src/deployArgent.ts @@ -0,0 +1,94 @@ +import { RpcProvider, Account, ec, hash, CallData, num } from "starknet"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const PROVIDER = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL! }); +const TARGET_ADDRESS = BigInt(process.env.WALLET_ADDRESS!); + +async function findAndDeploy() { + const privateKey = process.env.PRIVATE_KEY!; + const publicKey = ec.starkCurve.getStarkKey(privateKey); + console.log(`🔑 Public Key: ${publicKey}`); + console.log(`🎯 Target Address: ${process.env.WALLET_ADDRESS}\n`); + + // ArgentX class hashes (Sepolia + Mainnet, various versions) + const argentClassHashes = [ + { name: "Argent v0.4.0", hash: "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f" }, + { name: "Argent v0.3.1", hash: "0x029927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b" }, + { name: "Argent v0.3.0", hash: "0x01a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003" }, + { name: "Argent v0.3.1 alt", hash: "0x1a7820094feaf82d53f53f214b81292d717e7bb9a92bb2488092cd306f3993f" }, + { name: "Argent Sepolia latest", hash: "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f" }, + { name: "Argent Cairo 1.0 v1", hash: "0x01148c31dfa5c4708a4e9cf1f2d3b26e8812c177bda19150757ca3ff74a4e3a0" }, + { name: "Argent Cairo 1.0 v2", hash: "0x023371b227eaecd8e8920cd429d2cd0f3fee6abaacca08d3ab82a7cdd", hash2: "0x05400e90f7b74d3fefba034769e661802e4f8f2ab0efbb1a0bd1dc3b82b48e5e" }, + ]; + + // Constructor formats to try + const constructorFormats = [ + { name: "(owner, guardian=0)", build: () => CallData.compile({ owner: publicKey, guardian: "0" }) }, + { name: "(signer, guardian=0)", build: () => CallData.compile({ signer: publicKey, guardian: "0" }) }, + { name: "(public_key)", build: () => CallData.compile({ public_key: publicKey }) }, + { name: "[publicKey, 0]", build: () => [publicKey, "0"] }, + { name: "[publicKey]", build: () => [publicKey] }, + ]; + + // Salt options + const salts = [ + { name: "publicKey", value: publicKey }, + { name: "0", value: "0" }, + ]; + + console.log(`🔍 Trying ${argentClassHashes.length} class hashes × ${constructorFormats.length} constructors × ${salts.length} salts...\n`); + + for (const ch of argentClassHashes) { + for (const cf of constructorFormats) { + for (const salt of salts) { + try { + const calldata = cf.build(); + const computed = hash.calculateContractAddressFromHash( + salt.value, + ch.hash, + calldata, + 0 + ); + + if (BigInt(computed) === TARGET_ADDRESS) { + console.log(`✅ MATCH FOUND!`); + console.log(` Class Hash: ${ch.name} (${ch.hash})`); + console.log(` Constructor: ${cf.name}`); + console.log(` Salt: ${salt.name}`); + console.log(` Address: ${computed}\n`); + + // Now deploy + console.log(`🚀 Deploying account...`); + const account = new Account({ provider: PROVIDER, address: computed, signer: privateKey }); + + const deployResponse = await account.deployAccount({ + classHash: ch.hash, + constructorCalldata: calldata, + addressSalt: salt.value, + }); + + console.log(`📝 TX Hash: ${deployResponse.transaction_hash}`); + console.log(`⏳ Waiting for confirmation...`); + await PROVIDER.waitForTransaction(deployResponse.transaction_hash); + console.log(`✅ Account deployed successfully at ${deployResponse.contract_address}!`); + return; + } + } catch { + // Skip invalid combinations silently + } + } + } + } + + console.log(`❌ No match found with standard ArgentX class hashes.`); + console.log(`\n💡 Your best options:`); + console.log(` 1. Open Ready Wallet (ArgentX) in your browser`); + console.log(` 2. Make ANY transaction from it (even a 0 STRK transfer to yourself)`); + console.log(` 3. This will auto-deploy the account contract`); + console.log(` 4. Then the transfer agent script will work!\n`); + console.log(` OR: Use 'sncast account create' to make a new compatible wallet.`); +} + +findAndDeploy().catch(console.error); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..37e533d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import { runTransferAgent } from "./transferAgent"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +runTransferAgent({ + tokenAddress: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", // STRK on StarkNet + recipient: process.env.RECIPIENT_ADDRESS!, + transferAmount: BigInt("1000000000000000"), // 0.001 ETH in wei + transferThreshold: BigInt("5000000000000000"), // only transfer if balance > 0.005 ETH + alertThreshold: BigInt("2000000000000000"), // alert if balance < 0.002 ETH +}); diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..a55b699 --- /dev/null +++ b/src/test.ts @@ -0,0 +1,23 @@ +import { RpcProvider, Contract } from "starknet"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const PROVIDER = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL! }); +const ERC20_ABI = [ + { + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "core::starknet::contract_address::ContractAddress" }], + outputs: [{ name: "balance", type: "core::integer::u256" }], + state_mutability: "view", + } +] as const; + +async function test() { + const contract = new Contract({ abi: ERC20_ABI, address: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", providerOrAccount: PROVIDER }); + const result = await contract.balanceOf(process.env.WALLET_ADDRESS!); + console.log("RESULT TYPE:", typeof result); + console.log("RESULT:", result); +} +test(); diff --git a/src/transferAgent.ts b/src/transferAgent.ts new file mode 100644 index 0000000..d47f047 --- /dev/null +++ b/src/transferAgent.ts @@ -0,0 +1,179 @@ +import { RpcProvider, Account, Contract } from "starknet"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +// ─── CONFIG ────────────────────────────────────────────────────────────────── +const PROVIDER = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL! }); + +const ACCOUNT = new Account({ + provider: PROVIDER, + address: process.env.WALLET_ADDRESS!, + signer: process.env.PRIVATE_KEY!, + cairoVersion: "1" +}); + +// ERC-20 ABI (minimal — only what we need) +const ERC20_ABI = [ + { + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "core::starknet::contract_address::ContractAddress" }], + outputs: [{ name: "balance", type: "core::integer::u256" }], + state_mutability: "view", + }, + { + name: "transfer", + type: "function", + inputs: [ + { name: "recipient", type: "core::starknet::contract_address::ContractAddress" }, + { name: "amount", type: "core::integer::u256" }, + ], + outputs: [{ name: "success", type: "core::bool" }], + state_mutability: "external", + }, +] as const; + +// Execution log store +const executionLog: ExecutionRecord[] = []; + +interface ExecutionRecord { + timestamp: string; + action: string; + status: "success" | "skipped" | "error"; + details: string; +} + +// ─── 1. FETCH WALLET BALANCE ───────────────────────────────────────────────── +async function fetchWalletBalance(tokenAddress: string): Promise { + const contract = new Contract({ abi: ERC20_ABI, address: tokenAddress, providerOrAccount: PROVIDER }); + const result = await contract.balanceOf(ACCOUNT.address); + + // In starknet.js v9, core::integer::u256 is auto-parsed to a bigint + const balance = BigInt(typeof result === "bigint" ? result : (result as any).balance); + console.log(`💰 Balance: ${balance.toString()} (raw units)`); + return balance; +} + +// ─── 2. VALIDATE TRANSFER CONDITION ───────────────────────────────────────── +function validateTransferCondition(balance: bigint, threshold: bigint): boolean { + const isValid = balance > threshold; + console.log(`✅ Condition check: ${balance} > ${threshold} = ${isValid}`); + return isValid; +} + +// ─── 3. TRANSFER TOKENS ───────────────────────────────────────────────────── +async function transferTokens( + tokenAddress: string, + recipient: string, + amount: bigint +): Promise { + const contract = new Contract({ abi: ERC20_ABI, address: tokenAddress, providerOrAccount: ACCOUNT }); + // starknet.js v9 automatically converts bigints to u256 for Cairo 1 contracts + const call = contract.populate("transfer", { recipient, amount }); + const tx = await ACCOUNT.execute(call); + await PROVIDER.waitForTransaction(tx.transaction_hash); + + console.log(`🚀 Transfer complete. TX: ${tx.transaction_hash}`); + return tx.transaction_hash; +} + +// ─── 4. CALL ANOTHER AGENT/FUNCTION (COMPOSABILITY) ───────────────────────── +// This is the composability hook — swap in any downstream agent or contract call +async function callNextAgent(txHash: string, context: object): Promise { + console.log(`🔗 Calling downstream agent with context:`, context); + + // Example: call a notification agent, a DeFi protocol, or another AI agent + // await notificationAgent.run(context); + // await anotherContract.method(txHash); + + // For now, we simulate it: + console.log(` → Downstream agent received TX: ${txHash}`); +} + +// ─── 5. LOG EXECUTION RESULT ───────────────────────────────────────────────── +function logExecutionResult(record: ExecutionRecord): void { + executionLog.push(record); + const icon = record.status === "success" ? "✅" : record.status === "skipped" ? "⏭️" : "❌"; + console.log(`${icon} [LOG] ${record.timestamp} | ${record.action} | ${record.details}`); +} + +// ─── 6. TRIGGER ALERT ──────────────────────────────────────────────────────── +async function triggerAlert(balance: bigint, alertThreshold: bigint): Promise { + if (balance < alertThreshold) { + const message = `⚠️ ALERT: Balance ${balance} dropped below threshold ${alertThreshold}!`; + console.warn(message); + + // Hook your alerting system here: + // await sendTelegramAlert(message); + // await sendEmailAlert(message); + // await postToSlack(message); + } +} + +// ─── MAIN AGENT RUNNER ─────────────────────────────────────────────────────── +export async function runTransferAgent(config: { + tokenAddress: string; // ERC-20 token contract + recipient: string; // Who to send to + transferAmount: bigint; // How much to send + transferThreshold: bigint; // Only transfer if balance > this + alertThreshold: bigint; // Alert if balance drops below this +}) { + const timestamp = new Date().toISOString(); + console.log(`\n🤖 Transfer Agent starting at ${timestamp}\n`); + + try { + // Step 1: Fetch balance + const balance = await fetchWalletBalance(config.tokenAddress); + + // Step 2: Validate condition + const shouldTransfer = validateTransferCondition(balance, config.transferThreshold); + + if (shouldTransfer) { + // Step 3: Execute transfer + const txHash = await transferTokens( + config.tokenAddress, + config.recipient, + config.transferAmount + ); + + // Step 4: Call next agent (composability) + await callNextAgent(txHash, { balance, txHash, timestamp }); + + // Step 5: Log success + logExecutionResult({ + timestamp, + action: "TRANSFER", + status: "success", + details: `Sent ${config.transferAmount} to ${config.recipient}. TX: ${txHash}`, + }); + + // Step 6: Check post-transfer balance & alert if needed + const newBalance = await fetchWalletBalance(config.tokenAddress); + await triggerAlert(newBalance, config.alertThreshold); + + } else { + // Step 5: Log skip + logExecutionResult({ + timestamp, + action: "TRANSFER", + status: "skipped", + details: `Balance ${balance} did not exceed threshold ${config.transferThreshold}`, + }); + + // Still check alert even when skipping + await triggerAlert(balance, config.alertThreshold); + } + + } catch (err: any) { + logExecutionResult({ + timestamp, + action: "TRANSFER", + status: "error", + details: err.message, + }); + throw err; + } + + console.log(`\n📋 Execution Log:`, executionLog); +} diff --git a/test_balance.js b/test_balance.js new file mode 100644 index 0000000..2a1ae30 --- /dev/null +++ b/test_balance.js @@ -0,0 +1,30 @@ +const { RpcProvider, Contract, uint256 } = require("starknet"); + +const ABI = [{ + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "core::starknet::contract_address::ContractAddress" }], + outputs: [{ name: "balance", type: "core::integer::u256" }], + state_mutability: "view", +}]; + +const STRK_ADDR = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +const WALLET = "0x02f03858800b1f0a367ff544094984bE118Ebef1b1BC319cB84148CC2D05D070"; + +async function check(name, url) { + const provider = new RpcProvider({ nodeUrl: url }); + const contract = new Contract(ABI, STRK_ADDR, provider); + try { + const res = await contract.balanceOf(WALLET); + console.log(`${name}: ${uint256.uint256ToBN(res.balance).toString()}`); + } catch(e) { + console.log(`${name} error:`, e.message); + } +} + +async function main() { + await check("Mainnet (Lava)", "https://rpc.starknet.lava.build"); + await check("Sepolia (Blast)", "https://starknet-sepolia.public.blastapi.io"); + await check("Sepolia (Nethermind)", "https://free-rpc.nethermind.io/sepolia-juno"); +} +main(); diff --git a/test_balance2.js b/test_balance2.js new file mode 100644 index 0000000..a580e47 --- /dev/null +++ b/test_balance2.js @@ -0,0 +1,28 @@ +const { RpcProvider, Contract, uint256 } = require("starknet"); + +const ABI = [{ + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "core::starknet::contract_address::ContractAddress" }], + outputs: [{ name: "balance", type: "core::integer::u256" }], + state_mutability: "view", +}]; + +const STRK_ADDR = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +const WALLET = "0x02f03858800b1f0a367ff544094984bE118Ebef1b1BC319cB84148CC2D05D070"; + +async function check(name, url) { + const provider = new RpcProvider({ nodeUrl: url }); + const contract = new Contract(ABI, STRK_ADDR, provider); + try { + const res = await contract.balanceOf(WALLET); + console.log(`${name}: ${uint256.uint256ToBN(res.balance).toString()}`); + } catch(e) { + console.log(`${name} error:`, e.message); + } +} + +async function main() { + await check("Sepolia (Cartridge)", "https://api.cartridge.gg/x/starknet/sepolia"); +} +main(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a0bfb2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "outDir": "dist", + "rootDir": "src" + } +}