diff --git a/Cairo_Test/ERC-20_Cairo/.env.example b/Cairo_Test/ERC-20_Cairo/.env.example new file mode 100644 index 0000000..11601f0 --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/.env.example @@ -0,0 +1,14 @@ +# Account +ACCOUNT_NAME=my_account +ACCOUNT_ADDRESS=0x... +ACCOUNT_PUBLIC_KEY=0x... +ACCOUNT_SALT=0x... +ACCOUNT_CLASS_HASH=0x... + +# Contract +CONTRACT_CLASS_HASH=0x... +CONTRACT_ADDRESS=0x... + +# Network +NETWORK=alpha-sepolia +RPC_URL=https://api.zan.top/public/starknet-sepolia/rpc/v0_10 diff --git a/Cairo_Test/ERC-20_Cairo/.gitignore b/Cairo_Test/ERC-20_Cairo/.gitignore new file mode 100644 index 0000000..1d1d31f --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/.gitignore @@ -0,0 +1,6 @@ +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ +.env diff --git a/Cairo_Test/ERC-20_Cairo/README.md b/Cairo_Test/ERC-20_Cairo/README.md new file mode 100644 index 0000000..03f8ac9 --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/README.md @@ -0,0 +1,34 @@ +# ERC-20 Cairo (RestrictedToken) + +Sensitive deployment details (addresses, class hashes, transaction hashes, keys) are stored in `.env`. +Copy `.env.example` to `.env` and fill in your values: + +```bash +cp .env.example .env +``` + +> ⚠️ Never commit `.env` — it is listed in `.gitignore`. + +## Deployment Info + +See `.env` for: +- Account address, public key, salt, class hash +- Contract class hash and contract address +- Network and RPC URL + +## Voyager + +Contract can be viewed at: +`https://sepolia.voyager.online/contract/` + +## Commands + +### Declare +```bash +sncast --profile default declare --contract-name RestrictedToken +``` + +### Deploy +```bash +sncast --profile default deploy --class-hash --constructor-calldata +``` diff --git a/Cairo_Test/ERC-20_Cairo/Scarb.lock b/Cairo_Test/ERC-20_Cairo/Scarb.lock new file mode 100644 index 0000000..d3a8707 --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "first_cairo" +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/Cairo_Test/ERC-20_Cairo/Scarb.toml b/Cairo_Test/ERC-20_Cairo/Scarb.toml new file mode 100644 index 0000000..07b702f --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/Scarb.toml @@ -0,0 +1,52 @@ +[package] +name = "first_cairo" +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/Cairo_Test/ERC-20_Cairo/snfoundry.toml b/Cairo_Test/ERC-20_Cairo/snfoundry.toml new file mode 100644 index 0000000..565235c --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/snfoundry.toml @@ -0,0 +1,17 @@ +# 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 = "my_account" # 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 +[sncast.default] +url = "https://api.zan.top/public/starknet-sepolia/rpc/v0_10" +account = "my_account" +wait-params = { timeout = 300, retry-interval = 10 } +block-explorer = "Voyager" +show-explorer-links = true \ No newline at end of file diff --git a/Cairo_Test/ERC-20_Cairo/src/lib.cairo b/Cairo_Test/ERC-20_Cairo/src/lib.cairo new file mode 100644 index 0000000..c8eff99 --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/src/lib.cairo @@ -0,0 +1,298 @@ +#[starknet::contract] +pub mod RestrictedToken { + use core::num::traits::Zero; + use starknet::ContractAddress; + use starknet::get_caller_address; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + + // max amount per transfer + const DEFAULT_MAX_LIMIT: u256 = 10000; + const TOKEN_NAME: felt252 = 'Restricted Token'; + const TOKEN_SYMBOL: felt252 = 'RST'; + const TOKEN_DECIMALS: u8 = 0; + + #[storage] + struct Storage { + admin: ContractAddress, + balances: Map, + allowances: Map<(ContractAddress, ContractAddress), u256>, + total_supply: u256, + max_transfer_limit: u256, + revoked: Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + Transfer: Transfer, + Approval: Approval, + Burn: Burn, + Revoked: Revoked, + Reinstated: Reinstated, + LimitUpdated: LimitUpdated, + } + + #[derive(Drop, starknet::Event)] + struct Transfer { + from: ContractAddress, + to: ContractAddress, + amount: u256, + } + + #[derive(Drop, starknet::Event)] + struct Approval { + owner: ContractAddress, + spender: ContractAddress, + amount: u256, + } + + #[derive(Drop, starknet::Event)] + struct Burn { + from: ContractAddress, + amount: u256, + } + + #[derive(Drop, starknet::Event)] + struct Revoked { + account: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + struct Reinstated { + account: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + struct LimitUpdated { + old_limit: u256, + new_limit: u256, + } + + #[starknet::interface] + pub trait IRestrictedToken { + fn name(self: @TContractState) -> felt252; + fn symbol(self: @TContractState) -> felt252; + fn decimals(self: @TContractState) -> u8; + fn transfer(ref self: TContractState, to: ContractAddress, amount: u256); + fn approve(ref self: TContractState, spender: ContractAddress, amount: u256); + fn transfer_from(ref self: TContractState, from: ContractAddress, to: ContractAddress, amount: u256); + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256; + fn total_supply(self: @TContractState) -> u256; + + fn mint(ref self: TContractState, to: ContractAddress, amount: u256); + fn burn(ref self: TContractState, from: ContractAddress, amount: u256); + fn revoke(ref self: TContractState, account: ContractAddress); + fn reinstate(ref self: TContractState, account: ContractAddress); + fn update_transfer_limit(ref self: TContractState, new_limit: u256); + + fn is_revoked(self: @TContractState, account: ContractAddress) -> bool; + fn get_transfer_limit(self: @TContractState) -> u256; + fn get_admin(self: @TContractState) -> ContractAddress; + } + + #[constructor] + fn constructor(ref self: ContractState, admin: ContractAddress, initial_supply: u256) { + assert(!admin.is_zero(), 'Admin cannot be zero address'); + + self.admin.write(admin); + self.max_transfer_limit.write(DEFAULT_MAX_LIMIT); + + self.balances.entry(admin).write(initial_supply); + self.total_supply.write(initial_supply); + + self.emit(Transfer { + from: Zero::zero(), + to: admin, + amount: initial_supply, + }); + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + + fn assert_only_admin(self: @ContractState) { + assert( + self.admin.read() == get_caller_address(), + 'Caller is not admin' + ); + } + + fn assert_not_revoked(self: @ContractState, account: ContractAddress) { + assert( + !self.revoked.entry(account).read(), + 'Account is revoked' + ); + } + + // shared by transfer and transfer_from + fn _transfer( + ref self: ContractState, + from: ContractAddress, + to: ContractAddress, + amount: u256 + ) { + assert(!to.is_zero(), 'Cannot transfer to zero address'); + assert(amount > 0, 'Amount must be greater than 0'); + + assert( + amount <= self.max_transfer_limit.read(), + 'Exceeds max transfer limit' + ); + + self.assert_not_revoked(from); + self.assert_not_revoked(to); + + let from_balance = self.balances.entry(from).read(); + assert(from_balance >= amount, 'Insufficient balance'); + + self.balances.entry(from).write(from_balance - amount); + self.balances.entry(to).write(self.balances.entry(to).read() + amount); + + self.emit(Transfer { from, to, amount }); + } + } + + #[abi(embed_v0)] + impl RestrictedTokenImpl of IRestrictedToken { + + // Standard ERC-20 view functions + fn name(self: @ContractState) -> felt252 { + TOKEN_NAME + } + + fn symbol(self: @ContractState) -> felt252 { + TOKEN_SYMBOL + } + + fn decimals(self: @ContractState) -> u8 { + TOKEN_DECIMALS + } + + fn transfer(ref self: ContractState, to: ContractAddress, amount: u256) { + let caller = get_caller_address(); + self._transfer(caller, to, amount); + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) { + assert(!spender.is_zero(), 'Cannot approve zero address'); + let caller = get_caller_address(); + self.assert_not_revoked(caller); + self.allowances.entry((caller, spender)).write(amount); + self.emit(Approval { owner: caller, spender, amount }); + } + + fn transfer_from( + ref self: ContractState, + from: ContractAddress, + to: ContractAddress, + amount: u256 + ) { + let caller = get_caller_address(); + + let allowed = self.allowances.entry((from, caller)).read(); + assert(allowed >= amount, 'Allowance exceeded'); + + self.allowances.entry((from, caller)).write(allowed - amount); + + self._transfer(from, to, amount); + } + + fn mint(ref self: ContractState, to: ContractAddress, amount: u256) { + self.assert_only_admin(); + assert(!to.is_zero(), 'Cannot mint to zero address'); + assert(amount > 0, 'Mint amount must be > 0'); + + self.balances.entry(to).write(self.balances.entry(to).read() + amount); + self.total_supply.write(self.total_supply.read() + amount); + + self.emit(Transfer { + from: Zero::zero(), + to, + amount, + }); + } + + fn burn(ref self: ContractState, from: ContractAddress, amount: u256) { + self.assert_only_admin(); + assert(!from.is_zero(), 'Cannot burn from zero address'); + assert(amount > 0, 'Burn amount must be > 0'); + + let balance = self.balances.entry(from).read(); + assert(balance >= amount, 'Burn exceeds balance'); + + // Deduct from sender + self.balances.entry(from).write(balance - amount); + + self.total_supply.write(self.total_supply.read() - amount); + + // Emit Transfer to zero address + let zero_address: ContractAddress = Zero::zero(); + self.emit(Transfer { + from, + to: zero_address, + amount, + }); + + // Emit Burn event + self.emit(Burn { from, amount }); + } + + fn revoke(ref self: ContractState, account: ContractAddress) { + self.assert_only_admin(); + assert(!account.is_zero(), 'Cannot revoke zero address'); + assert(!self.revoked.entry(account).read(), 'Already revoked'); + + self.revoked.entry(account).write(true); + self.emit(Revoked { account }); + } + + fn reinstate(ref self: ContractState, account: ContractAddress) { + self.assert_only_admin(); + assert(self.revoked.entry(account).read(), 'Account is not revoked'); + + self.revoked.entry(account).write(false); + self.emit(Reinstated { account }); + } + + fn update_transfer_limit(ref self: ContractState, new_limit: u256) { + self.assert_only_admin(); + assert(new_limit > 0, 'Limit must be greater than 0'); + + let old_limit = self.max_transfer_limit.read(); + self.max_transfer_limit.write(new_limit); + + self.emit(LimitUpdated { old_limit, new_limit }); + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.balances.entry(account).read() + } + + fn allowance( + self: @ContractState, + owner: ContractAddress, + spender: ContractAddress + ) -> u256 { + self.allowances.entry((owner, spender)).read() + } + + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + fn is_revoked(self: @ContractState, account: ContractAddress) -> bool { + self.revoked.entry(account).read() + } + + fn get_transfer_limit(self: @ContractState) -> u256 { + self.max_transfer_limit.read() + } + + fn get_admin(self: @ContractState) -> ContractAddress { + self.admin.read() + } + } +} diff --git a/Cairo_Test/ERC-20_Cairo/tests/test_contract.cairo b/Cairo_Test/ERC-20_Cairo/tests/test_contract.cairo new file mode 100644 index 0000000..81d2b99 --- /dev/null +++ b/Cairo_Test/ERC-20_Cairo/tests/test_contract.cairo @@ -0,0 +1,557 @@ +use starknet::{ContractAddress, SyscallResultTrait}; +use core::num::traits::Zero; +use snforge_std::{declare, DeclareResultTrait, ContractClassTrait, CheatSpan, cheat_caller_address}; +use first_cairo::RestrictedToken::{ + IRestrictedTokenDispatcher, IRestrictedTokenDispatcherTrait +}; + +fn ADMIN() -> ContractAddress { + 'admin'.try_into().unwrap() +} + +fn USER1() -> ContractAddress { + 'user1'.try_into().unwrap() +} + +fn USER2() -> ContractAddress { + 'user2'.try_into().unwrap() +} + +fn deploy_contract(initial_supply: u256) -> IRestrictedTokenDispatcher { + let mut calldata = array![]; + ADMIN().serialize(ref calldata); + initial_supply.serialize(ref calldata); + + let (contract_address, _) = declare("RestrictedToken") + .unwrap() + .contract_class() + .deploy(@calldata) + .unwrap(); + IRestrictedTokenDispatcher { contract_address } +} + +#[test] +fn test_constructor_and_view_functions() { + let contract = deploy_contract(1000); + + assert(contract.name() == 'Restricted Token', 'Name mismatch'); + assert(contract.symbol() == 'RST', 'Symbol mismatch'); + assert(contract.decimals() == 0, 'Decimals mismatch'); + assert(contract.get_transfer_limit() == 10000, 'Default limit wrong'); + assert(contract.get_admin() == ADMIN(), 'Admin not set'); + assert(contract.balance_of(ADMIN()) == 1000, 'Initial supply wrong'); + assert(contract.total_supply() == 1000, 'Total supply wrong'); +} + +#[test] +fn test_transfer_success() { + let contract = deploy_contract(0); + + // Mint to USER1 first + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Transfer from USER1 to USER2 + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.transfer(USER2(), 100); + + assert(contract.balance_of(USER1()) == 900, 'Sender balance wrong'); + assert(contract.balance_of(USER2()) == 100, 'Recipient balance wrong'); +} + +#[test] +#[should_panic(expected: 'Insufficient balance')] +fn test_transfer_insufficient_balance() { + let contract = deploy_contract(100); + + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.transfer(USER2(), 200); +} + +#[test] +#[should_panic(expected: 'Amount must be greater than 0')] +fn test_transfer_zero_amount() { + let contract = deploy_contract(1000); + + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.transfer(USER1(), 0); +} + +#[test] +#[should_panic(expected: 'Cannot transfer to zero address')] +fn test_transfer_to_zero_address() { + let contract = deploy_contract(1000); + + // Use actual zero address (0x0) + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.transfer(Zero::zero(), 100); +} + +#[test] +fn test_approve_and_transfer_from() { + let contract = deploy_contract(0); + + // Mint to USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Approve USER2 to spend + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.approve(USER2(), 100); + + assert(contract.allowance(USER1(), USER2()) == 100, 'Allowance mismatch'); + + // Transfer from USER1 using USER2's allowance + cheat_caller_address(contract.contract_address, USER2(), CheatSpan::TargetCalls(1)); + contract.transfer_from(USER1(), USER2(), 50); + + assert(contract.balance_of(USER1()) == 950, 'Balance after transfer wrong'); + assert(contract.balance_of(USER2()) == 50, 'Recipient balance wrong'); + assert(contract.allowance(USER1(), USER2()) == 50, 'Allowance not deducted'); +} + +#[test] +#[should_panic(expected: 'Allowance exceeded')] +fn test_transfer_from_exceeds_allowance() { + let contract = deploy_contract(0); + + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.approve(USER2(), 50); + + cheat_caller_address(contract.contract_address, USER2(), CheatSpan::TargetCalls(1)); + contract.transfer_from(USER1(), USER2(), 100); +} + +#[test] +fn test_mint_and_burn() { + let contract = deploy_contract(0); + + // Mint + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 500); + + assert(contract.balance_of(USER1()) == 500, 'Minted balance wrong'); + assert(contract.total_supply() == 500, 'Total supply after mint wrong'); + + // Burn + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.burn(USER1(), 100); + + assert(contract.balance_of(USER1()) == 400, 'Balance after burn wrong'); + assert(contract.total_supply() == 400, 'Total supply after burn wrong'); +} + +#[test] +#[should_panic(expected: 'Caller is not admin')] +fn test_mint_by_non_admin() { + let contract = deploy_contract(0); + + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.mint(USER2(), 100); +} + +#[test] +#[should_panic(expected: 'Account is revoked')] +fn test_revoke_prevents_transfer() { + let contract = deploy_contract(0); + + // Mint to USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Revoke USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + assert(contract.is_revoked(USER1()), 'Not revoked'); + + // Try transfer from revoked account - should fail + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.transfer(USER2(), 50); +} + +#[test] +fn test_reinstate_revoked_account() { + let contract = deploy_contract(0); + + // Mint to USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Revoke USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + // Reinstate USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.reinstate(USER1()); + + assert(!contract.is_revoked(USER1()), 'Still revoked after reinstate'); +} + +#[test] +fn test_update_transfer_limit() { + let contract = deploy_contract(0); + + assert(contract.get_transfer_limit() == 10000, 'Default limit wrong'); + + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.update_transfer_limit(5000); + + assert(contract.get_transfer_limit() == 5000, 'Limit not updated'); +} + +#[test] +#[should_panic(expected: 'Exceeds max transfer limit')] +fn test_transfer_exceeds_limit() { + let contract = deploy_contract(0); + + // Update limit to 100 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.update_transfer_limit(100); + + // Mint to USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Try to transfer 200 (exceeds limit of 100) + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.transfer(USER2(), 200); +} + +// More tests for revoke feature +// Testing if revoked users can receive tokens (they shouldn't!) + +#[test] +#[should_panic(expected: 'Account is revoked')] +fn test_revoke_prevents_receiving_transfer() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Ban USER2 from using the contract + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER2()); + + // USER1 tries to send tokens to banned USER2 - this should fail! + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.transfer(USER2(), 50); +} + +// Testing if banned users can approve others to spend their tokens (they can't!) +#[test] +#[should_panic(expected: 'Account is revoked')] +fn test_revoke_prevents_approval() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Ban USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + // Banned USER1 tries to approve USER2 - this should fail! + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.approve(USER2(), 100); +} + +// Testing transfer_from when the sender is banned +#[test] +#[should_panic(expected: 'Account is revoked')] +fn test_transfer_from_with_revoked_sender() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // USER1 allows USER2 to spend 100 tokens + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.approve(USER2(), 100); + + // Admin bans USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + // USER2 tries to use the approval to move tokens from banned USER1 - should fail! + cheat_caller_address(contract.contract_address, USER2(), CheatSpan::TargetCalls(1)); + contract.transfer_from(USER1(), USER2(), 50); +} + +// Testing transfer_from when the person receiving is banned +#[test] +#[should_panic(expected: 'Account is revoked')] +fn test_transfer_from_with_revoked_recipient() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // USER1 allows USER2 to spend 100 tokens + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.approve(USER2(), 100); + + // Admin bans themselves (just for testing!) + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(ADMIN()); + + // USER2 tries to send tokens to banned ADMIN - should fail! + cheat_caller_address(contract.contract_address, USER2(), CheatSpan::TargetCalls(1)); + contract.transfer_from(USER1(), ADMIN(), 50); +} + +// Testing if we can ban the zero address (we shouldn't be able to!) +#[test] +#[should_panic(expected: 'Cannot revoke zero address')] +fn test_revoke_zero_address() { + let contract = deploy_contract(0); + + // Try to ban address 0x0 - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(Zero::zero()); +} + +// Testing if we can ban someone who's already banned (we can't!) +#[test] +#[should_panic(expected: 'Already revoked')] +fn test_revoke_already_revoked_account() { + let contract = deploy_contract(0); + + // Ban USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + // Try to ban USER1 again - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); +} + +// Testing if we can unban someone who was never banned (we can't!) +#[test] +#[should_panic(expected: 'Account is not revoked')] +fn test_reinstate_non_revoked_account() { + let contract = deploy_contract(0); + + // Try to unban USER1 who was never banned - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.reinstate(USER1()); +} + +// Testing if unbanning someone lets them transfer again (it should!) +#[test] +fn test_reinstate_allows_transfer() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // Ban USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + // Unban USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.reinstate(USER1()); + + // Now USER1 should be able to transfer again! + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.transfer(USER2(), 100); + + assert(contract.balance_of(USER1()) == 900, 'Balance after transfer wrong'); + assert(contract.balance_of(USER2()) == 100, 'Recipient balance wrong'); +} + +// Tests for minting (creating new tokens) + +// Testing if we can mint 0 tokens (we can't!) +#[test] +#[should_panic(expected: 'Mint amount must be > 0')] +fn test_mint_zero_amount() { + let contract = deploy_contract(0); + + // Try to mint 0 tokens - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 0); +} + +// Testing if we can mint tokens to address 0x0 (we can't!) +#[test] +#[should_panic(expected: 'Cannot mint to zero address')] +fn test_mint_to_zero_address() { + let contract = deploy_contract(0); + + // Try to mint to address 0x0 - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(Zero::zero(), 100); +} + +// Tests for burning (destroying tokens) + +// Testing if we can burn more tokens than someone has (we can't!) +#[test] +#[should_panic(expected: 'Burn exceeds balance')] +fn test_burn_exceeds_balance() { + let contract = deploy_contract(0); + + // Give USER1 only 100 tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 100); + + // Try to burn 200 tokens (more than they have) - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.burn(USER1(), 200); +} + +// Testing if we can burn 0 tokens (we can't!) +#[test] +#[should_panic(expected: 'Burn amount must be > 0')] +fn test_burn_zero_amount() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 100); + + // Try to burn 0 tokens - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.burn(USER1(), 0); +} + +// Testing if we can burn from address 0x0 (we can't!) +#[test] +#[should_panic(expected: 'Cannot burn from zero address')] +fn test_burn_from_zero_address() { + let contract = deploy_contract(0); + + // Try to burn from address 0x0 - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.burn(Zero::zero(), 100); +} + +// Testing if regular users can burn tokens (they can't, only admin!) +#[test] +#[should_panic(expected: 'Caller is not admin')] +fn test_burn_by_non_admin() { + let contract = deploy_contract(0); + + // Give USER1 some tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 100); + + // USER1 tries to burn their own tokens - this should fail! (only admin can burn) + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.burn(USER1(), 50); +} + +// Tests for approve function + +// Testing if we can approve address 0x0 to spend our tokens (we can't!) +#[test] +#[should_panic(expected: 'Cannot approve zero address')] +fn test_approve_zero_address() { + let contract = deploy_contract(1000); + + // Try to approve address 0x0 - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.approve(Zero::zero(), 100); +} + +// Tests for transfer limit (max amount you can send at once) + +// Testing if we can set the limit to 0 (we can't!) +#[test] +#[should_panic(expected: 'Limit must be greater than 0')] +fn test_update_transfer_limit_to_zero() { + let contract = deploy_contract(0); + + // Try to set limit to 0 - this should fail! + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.update_transfer_limit(0); +} + +// Testing if regular users can change the limit (they can't, only admin!) +#[test] +#[should_panic(expected: 'Caller is not admin')] +fn test_update_transfer_limit_by_non_admin() { + let contract = deploy_contract(0); + + // USER1 tries to change the limit - this should fail! + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.update_transfer_limit(5000); +} + +// Testing if transfer_from also respects the limit (it should!) +#[test] +#[should_panic(expected: 'Exceeds max transfer limit')] +fn test_transfer_from_exceeds_limit() { + let contract = deploy_contract(0); + + // Set limit to only 100 tokens per transfer + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.update_transfer_limit(100); + + // Give USER1 lots of tokens + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.mint(USER1(), 1000); + + // USER1 allows USER2 to spend 500 tokens + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.approve(USER2(), 500); + + // USER2 tries to transfer 200 (more than limit of 100) - this should fail! + cheat_caller_address(contract.contract_address, USER2(), CheatSpan::TargetCalls(1)); + contract.transfer_from(USER1(), USER2(), 200); +} + +// Tests to make sure only admin can do admin stuff + +// Testing if regular users can ban others (they can't!) +#[test] +#[should_panic(expected: 'Caller is not admin')] +fn test_revoke_by_non_admin() { + let contract = deploy_contract(0); + + // USER1 tries to ban USER2 - this should fail! (only admin can ban) + cheat_caller_address(contract.contract_address, USER1(), CheatSpan::TargetCalls(1)); + contract.revoke(USER2()); +} + +// Testing if regular users can unban others (they can't!) +#[test] +#[should_panic(expected: 'Caller is not admin')] +fn test_reinstate_by_non_admin() { + let contract = deploy_contract(0); + + // Admin bans USER1 + cheat_caller_address(contract.contract_address, ADMIN(), CheatSpan::TargetCalls(1)); + contract.revoke(USER1()); + + // USER2 tries to unban USER1 - this should fail! (only admin can unban) + cheat_caller_address(contract.contract_address, USER2(), CheatSpan::TargetCalls(1)); + contract.reinstate(USER1()); +} + +// Testing the constructor (when we first create the contract) + +// Testing if we can create a contract with admin = 0x0 (we can't!) +#[test] +#[should_panic(expected: 'Admin cannot be zero address')] +fn test_constructor_zero_admin() { + let mut calldata = array![]; + let zero_address: ContractAddress = Zero::zero(); + zero_address.serialize(ref calldata); + 1000_u256.serialize(ref calldata); + + // Try to deploy with zero address as admin - this should fail! + let contract_class = declare("RestrictedToken").unwrap().contract_class(); + let (_contract_address, _) = contract_class.deploy(@calldata).unwrap_syscall(); +} diff --git a/Cairo_Test/transfer-agent/.env.example b/Cairo_Test/transfer-agent/.env.example new file mode 100644 index 0000000..fb03de1 --- /dev/null +++ b/Cairo_Test/transfer-agent/.env.example @@ -0,0 +1,26 @@ +# Starknet RPC endpoint +STARKNET_RPC_URL=https://starknet-mainnet.public.blastapi.io + +# Agent wallet credentials +STARKNET_ACCOUNT_ADDRESS=0x... +STARKNET_PRIVATE_KEY=0x... + +# Transfer configuration +# Minimum balance (in token units) required before a transfer is allowed +TRANSFER_THRESHOLD=1.0 +# Amount to transfer when conditions are met +TRANSFER_AMOUNT=0.5 +# Recipient address for conditional transfers +TRANSFER_RECIPIENT=0x... +# Token to operate on: ETH | STRK | USDC | USDT +TRANSFER_TOKEN=STRK + +# Alert threshold: trigger alert when balance drops below this value +ALERT_THRESHOLD=0.2 + +# How often to poll (milliseconds) +POLL_INTERVAL_MS=30000 + +# Optional: AVNU paymaster for gasless transfers +# AVNU_PAYMASTER_URL=https://starknet.paymaster.avnu.fi +# AVNU_PAYMASTER_API_KEY=your_key diff --git a/Cairo_Test/transfer-agent/README.md b/Cairo_Test/transfer-agent/README.md new file mode 100644 index 0000000..d4a3cca --- /dev/null +++ b/Cairo_Test/transfer-agent/README.md @@ -0,0 +1,150 @@ +# Composable Autonomous Transfer Agent + +A multi-step autonomous agent that runs a conditional transfer workflow on Starknet. + +## What It Does + +Each polling cycle executes this pipeline: + +``` +Step 1 — fetch_balance → read token balance from chain +Step 2 — validate_condition → check balance > threshold AND balance >= transfer amount +Step 3 — execute_transfer → send tokens to recipient (skipped if condition not met) +Step 4 — post_transfer_hook → call downstream function / agent after a successful transfer +Step 5 — alert_check → fire an alert if balance drops below the alert threshold +``` + +Every step is logged with status (`ok` | `skipped` | `error` | `alert`) and structured data. + +## Setup + +```bash +cd examples/transfer-agent +npm install +cp .env.example .env +# Edit .env with your credentials and transfer parameters +``` + +### Required `.env` fields + +| Variable | Description | +|---|---| +| `STARKNET_RPC_URL` | Starknet RPC endpoint | +| `STARKNET_ACCOUNT_ADDRESS` | Your agent wallet address | +| `STARKNET_PRIVATE_KEY` | Private key (keep secret) | +| `TRANSFER_RECIPIENT` | Address to send tokens to | + +### Optional `.env` fields + +| Variable | Default | Description | +|---|---|---| +| `TRANSFER_TOKEN` | `STRK` | Token to operate on (`ETH`, `STRK`, `USDC`, `USDT`) | +| `TRANSFER_THRESHOLD` | `1.0` | Minimum balance required to trigger a transfer | +| `TRANSFER_AMOUNT` | `0.5` | Amount to transfer when conditions are met | +| `ALERT_THRESHOLD` | `0.2` | Balance level that triggers an alert | +| `POLL_INTERVAL_MS` | `30000` | How often to run the workflow (ms) | + +## Run + +```bash +# Production +npm start + +# Development (hot reload) +npm run dev +``` + +## Sample Output + +``` +============================================================ + Composable Transfer Agent — starting +============================================================ + Account : 0xabc... + Token : STRK + Threshold: 1.0 STRK + Transfer : 0.5 STRK + Alert at : 0.2 STRK + Interval : 30s +============================================================ + +── Run #1 [run-1-1716200000000] ────────────────────── + ✅ [fetch_balance] Balance fetched successfully + token: STRK + balance: 2.5 + ✅ [alert_check] Balance 2.500000 STRK is above alert threshold + ✅ [validate_condition] Condition met: balance 2.5 > threshold 1 + ✅ [execute_transfer] Transfer executed successfully + txHash: 0x123... + amount: 0.5 + recipient: 0xdef... + ✅ [post_transfer_hook] Downstream action completed + action: balance_recheck + newBalance: 2.0 + 📋 Summary: Transfer complete. tx=0x123... + ⏱ 2026-05-20T10:00:00.000Z → 2026-05-20T10:00:05.123Z +``` + +## Extending the Agent + +### Plug in a different downstream action (Step 4) + +Replace `downstreamAction()` in `index.ts` with any of: + +```typescript +// A2A task dispatch +import { StarknetA2AAdapter } from "@starknet-agentic/a2a"; +const task = await adapter.createTaskFromTransaction(txHash, "Transfer confirmed — rebalance portfolio"); + +// Contract invocation +await account.execute({ contractAddress, entrypoint: "on_transfer", calldata }); + +// HTTP webhook +await fetch(process.env.WEBHOOK_URL!, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ txHash, amount: CONFIG.TRANSFER_AMOUNT }), +}); + +// Another agent +await secondaryAgent.handleTransferEvent(txHash); +``` + +### Plug in a real alert channel (Step 5) + +Replace `dispatchAlert()` in `index.ts`: + +```typescript +// Slack +await fetch(process.env.SLACK_WEBHOOK_URL!, { + method: "POST", + body: JSON.stringify({ text: `Balance alert: ${balance} ${CONFIG.TRANSFER_TOKEN}` }), +}); + +// PagerDuty, email, SMS — same pattern +``` + +### Multi-token monitoring + +Instantiate multiple agents with different `TRANSFER_TOKEN` values, or extend +`fetchBalance()` to use `starknet_get_balances` (batch RPC) for a portfolio view. + +## Architecture + +``` +TransferAgent + └── executeWorkflow() ← orchestrator, runs every POLL_INTERVAL_MS + ├── fetchBalance() ← starknet.js ERC-20 balance_of call + ├── checkAlert() ← fires dispatchAlert() when below threshold + ├── validateCondition() ← pure logic, no network calls + ├── executeTransfer() ← account.execute() → transfer entrypoint + └── onPostTransfer() ← calls downstreamAction() (swap it out freely) +``` + +This follows the starknet-agentic layer model: + +``` +Agent Loop (this file) + → Starknet SDK (starknet.js v9) + → Starknet L2 +``` diff --git a/Cairo_Test/transfer-agent/index.ts b/Cairo_Test/transfer-agent/index.ts new file mode 100644 index 0000000..e7e2a58 --- /dev/null +++ b/Cairo_Test/transfer-agent/index.ts @@ -0,0 +1,564 @@ +/** + * Composable Autonomous Transfer Agent + * + * A multi-step agent workflow that: + * 1. Fetches wallet balance + * 2. Validates transfer conditions (balance > threshold) + * 3. Executes a token transfer when conditions are met + * 4. Calls a downstream agent / function after a successful transfer + * 5. Logs every execution step with structured output + * 6. Triggers alerts when balance drops below a configurable threshold + * + * Architecture follows the starknet-agentic layer model: + * Agent Loop → Starknet SDK (starknet.js) → Starknet L2 + * + * Run: + * cp .env.example .env # fill in your credentials + * npm start + */ + +import dotenv from "dotenv"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { + Account, + RpcProvider, + Contract, + CallData, + cairo, + uint256, +} from "starknet"; + +// --------------------------------------------------------------------------- +// Bootstrap +// --------------------------------------------------------------------------- + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: join(__dirname, ".env") }); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const CONFIG = { + RPC_URL: + process.env.STARKNET_RPC_URL || + "https://starknet-mainnet.public.blastapi.io", + ACCOUNT_ADDRESS: process.env.STARKNET_ACCOUNT_ADDRESS!, + PRIVATE_KEY: process.env.STARKNET_PRIVATE_KEY!, + + // Transfer settings + TRANSFER_TOKEN: (process.env.TRANSFER_TOKEN || "STRK") as TokenSymbol, + TRANSFER_THRESHOLD: parseFloat(process.env.TRANSFER_THRESHOLD || "1.0"), + TRANSFER_AMOUNT: process.env.TRANSFER_AMOUNT || "0.5", + TRANSFER_RECIPIENT: process.env.TRANSFER_RECIPIENT || "", + + // Alert threshold — fire an alert when balance drops below this + ALERT_THRESHOLD: parseFloat(process.env.ALERT_THRESHOLD || "0.2"), + + // Polling interval + POLL_INTERVAL_MS: parseInt(process.env.POLL_INTERVAL_MS || "30000", 10), +}; + +// --------------------------------------------------------------------------- +// Token registry +// --------------------------------------------------------------------------- + +type TokenSymbol = "ETH" | "STRK" | "USDC" | "USDT"; + +const TOKEN_ADDRESSES: Record = { + ETH: "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + STRK: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + USDC: "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8", + USDT: "0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8", +}; + +const TOKEN_DECIMALS: Record = { + ETH: 18, + STRK: 18, + USDC: 6, + USDT: 6, +}; + +// Minimal ERC-20 ABI (Cairo 1 style) +const ERC20_ABI = [ + { + type: "interface", + name: "openzeppelin::token::erc20::interface::IERC20", + items: [ + { + type: "function", + name: "balance_of", + inputs: [ + { + name: "account", + type: "core::starknet::contract_address::ContractAddress", + }, + ], + outputs: [{ type: "core::integer::u256" }], + state_mutability: "view", + }, + { + type: "function", + name: "transfer", + inputs: [ + { + name: "recipient", + type: "core::starknet::contract_address::ContractAddress", + }, + { name: "amount", type: "core::integer::u256" }, + ], + outputs: [{ type: "core::bool" }], + state_mutability: "external", + }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Execution log types +// --------------------------------------------------------------------------- + +type StepStatus = "ok" | "skipped" | "error" | "alert"; + +interface StepResult { + step: string; + status: StepStatus; + detail: string; + data?: Record; + timestamp: string; +} + +interface ExecutionLog { + runId: string; + startedAt: string; + steps: StepResult[]; + completedAt?: string; + summary?: string; +} + +// --------------------------------------------------------------------------- +// TransferAgent +// --------------------------------------------------------------------------- + +class TransferAgent { + private provider: RpcProvider; + private account: Account; + private isRunning = false; + private runCount = 0; + + constructor() { + this.provider = new RpcProvider({ nodeUrl: CONFIG.RPC_URL }); + this.account = new Account({ + provider: this.provider, + address: CONFIG.ACCOUNT_ADDRESS, + signer: CONFIG.PRIVATE_KEY, + }); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + async start(): Promise { + console.log("=".repeat(60)); + console.log(" Composable Transfer Agent — starting"); + console.log("=".repeat(60)); + console.log(` Account : ${this.account.address}`); + console.log(` Token : ${CONFIG.TRANSFER_TOKEN}`); + console.log(` Threshold: ${CONFIG.TRANSFER_THRESHOLD} ${CONFIG.TRANSFER_TOKEN}`); + console.log(` Transfer : ${CONFIG.TRANSFER_AMOUNT} ${CONFIG.TRANSFER_TOKEN}`); + console.log(` Alert at : ${CONFIG.ALERT_THRESHOLD} ${CONFIG.TRANSFER_TOKEN}`); + console.log(` Interval : ${CONFIG.POLL_INTERVAL_MS / 1000}s`); + console.log("=".repeat(60) + "\n"); + + this.isRunning = true; + await this.runLoop(); + } + + stop(): void { + this.isRunning = false; + console.log("\nAgent stopped. Total runs:", this.runCount); + } + + // ------------------------------------------------------------------------- + // Main loop + // ------------------------------------------------------------------------- + + private async runLoop(): Promise { + while (this.isRunning) { + this.runCount++; + const log = await this.executeWorkflow(); + this.printLog(log); + await this.sleep(CONFIG.POLL_INTERVAL_MS); + } + } + + // ------------------------------------------------------------------------- + // Step 1 — Fetch wallet balance + // ------------------------------------------------------------------------- + + private async fetchBalance(log: ExecutionLog): Promise { + const step = "fetch_balance"; + try { + const tokenAddress = TOKEN_ADDRESSES[CONFIG.TRANSFER_TOKEN]; + const decimals = TOKEN_DECIMALS[CONFIG.TRANSFER_TOKEN]; + + const contract = new Contract({ + abi: ERC20_ABI, + address: tokenAddress, + providerOrAccount: this.provider, + }); + + const raw = await contract.balance_of(this.account.address); + // starknet.js v8 with Cairo 1 ABI returns u256 as bigint + const rawBigInt: bigint = + typeof raw === "bigint" ? raw : BigInt(raw.toString()); + const balance = Number(rawBigInt) / 10 ** decimals; + + log.steps.push({ + step, + status: "ok", + detail: `Balance fetched successfully`, + data: { + token: CONFIG.TRANSFER_TOKEN, + balance, + rawWei: rawBigInt.toString(), + }, + timestamp: new Date().toISOString(), + }); + + return balance; + } catch (err) { + log.steps.push({ + step, + status: "error", + detail: `Failed to fetch balance: ${(err as Error).message}`, + timestamp: new Date().toISOString(), + }); + return null; + } + } + + // ------------------------------------------------------------------------- + // Step 2 — Validate transfer condition + // ------------------------------------------------------------------------- + + private validateCondition( + balance: number, + log: ExecutionLog + ): boolean { + const step = "validate_condition"; + const transferAmount = parseFloat(CONFIG.TRANSFER_AMOUNT); + const meetsThreshold = balance > CONFIG.TRANSFER_THRESHOLD; + const hasSufficientFunds = balance >= transferAmount; + const valid = meetsThreshold && hasSufficientFunds; + + log.steps.push({ + step, + status: valid ? "ok" : "skipped", + detail: valid + ? `Condition met: balance ${balance} > threshold ${CONFIG.TRANSFER_THRESHOLD}` + : `Condition not met: balance ${balance} (threshold ${CONFIG.TRANSFER_THRESHOLD}, need ${transferAmount})`, + data: { + balance, + threshold: CONFIG.TRANSFER_THRESHOLD, + transferAmount, + meetsThreshold, + hasSufficientFunds, + }, + timestamp: new Date().toISOString(), + }); + + return valid; + } + + // ------------------------------------------------------------------------- + // Step 3 — Execute transfer + // ------------------------------------------------------------------------- + + private async executeTransfer( + log: ExecutionLog + ): Promise { + const step = "execute_transfer"; + + if (!CONFIG.TRANSFER_RECIPIENT) { + log.steps.push({ + step, + status: "error", + detail: "TRANSFER_RECIPIENT is not configured", + timestamp: new Date().toISOString(), + }); + return null; + } + + try { + const tokenAddress = TOKEN_ADDRESSES[CONFIG.TRANSFER_TOKEN]; + const decimals = TOKEN_DECIMALS[CONFIG.TRANSFER_TOKEN]; + const amountFloat = parseFloat(CONFIG.TRANSFER_AMOUNT); + const amountWei = BigInt(Math.floor(amountFloat * 10 ** decimals)); + + // Build the transfer calldata + const calldata = CallData.compile({ + recipient: CONFIG.TRANSFER_RECIPIENT, + amount: cairo.uint256(amountWei), + }); + + const { transaction_hash } = await this.account.execute({ + contractAddress: tokenAddress, + entrypoint: "transfer", + calldata, + }); + + // Wait for acceptance + await this.provider.waitForTransaction(transaction_hash); + + log.steps.push({ + step, + status: "ok", + detail: `Transfer executed successfully`, + data: { + txHash: transaction_hash, + token: CONFIG.TRANSFER_TOKEN, + amount: CONFIG.TRANSFER_AMOUNT, + recipient: CONFIG.TRANSFER_RECIPIENT, + }, + timestamp: new Date().toISOString(), + }); + + return transaction_hash; + } catch (err) { + log.steps.push({ + step, + status: "error", + detail: `Transfer failed: ${(err as Error).message}`, + timestamp: new Date().toISOString(), + }); + return null; + } + } + + // ------------------------------------------------------------------------- + // Step 4 — Post-transfer downstream call + // + // This is the "call another function or agent" step. + // Replace / extend onPostTransfer() to chain into another agent, + // invoke a contract, or trigger an A2A task. + // ------------------------------------------------------------------------- + + private async onPostTransfer( + txHash: string, + log: ExecutionLog + ): Promise { + const step = "post_transfer_hook"; + try { + const result = await this.downstreamAction(txHash); + log.steps.push({ + step, + status: "ok", + detail: "Downstream action completed", + data: { txHash, ...result }, + timestamp: new Date().toISOString(), + }); + } catch (err) { + log.steps.push({ + step, + status: "error", + detail: `Downstream action failed: ${(err as Error).message}`, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Downstream action stub. + * + * Swap this out for: + * - An A2A task dispatch: adapter.createTaskFromTransaction(txHash, prompt) + * - A contract invocation: account.execute({ contractAddress, entrypoint, calldata }) + * - An HTTP webhook: fetch(WEBHOOK_URL, { method: "POST", body: JSON.stringify({txHash}) }) + * - Another agent call: secondaryAgent.handleTransferEvent(txHash) + */ + private async downstreamAction( + txHash: string + ): Promise> { + // Default: re-fetch balance to confirm the new state + const tokenAddress = TOKEN_ADDRESSES[CONFIG.TRANSFER_TOKEN]; + const decimals = TOKEN_DECIMALS[CONFIG.TRANSFER_TOKEN]; + + const contract = new Contract({ + abi: ERC20_ABI, + address: tokenAddress, + providerOrAccount: this.provider, + }); + + const raw = await contract.balance_of(this.account.address); + const rawBigInt: bigint = + typeof raw === "bigint" ? raw : BigInt(raw.toString()); + const newBalance = Number(rawBigInt) / 10 ** decimals; + + return { + action: "balance_recheck", + newBalance, + confirmedTx: txHash, + }; + } + + // ------------------------------------------------------------------------- + // Step 5 — Alert check + // ------------------------------------------------------------------------- + + private checkAlert(balance: number, log: ExecutionLog): void { + const step = "alert_check"; + const triggered = balance < CONFIG.ALERT_THRESHOLD; + + log.steps.push({ + step, + status: triggered ? "alert" : "ok", + detail: triggered + ? `ALERT: Balance ${balance.toFixed(6)} ${CONFIG.TRANSFER_TOKEN} is below alert threshold ${CONFIG.ALERT_THRESHOLD}` + : `Balance ${balance.toFixed(6)} ${CONFIG.TRANSFER_TOKEN} is above alert threshold`, + data: { + balance, + alertThreshold: CONFIG.ALERT_THRESHOLD, + triggered, + }, + timestamp: new Date().toISOString(), + }); + + if (triggered) { + // In production: send to PagerDuty, Slack, email, or an A2A alert agent + this.dispatchAlert(balance); + } + } + + /** + * Alert dispatch stub. + * + * Replace with your preferred notification channel: + * - Slack webhook + * - PagerDuty event + * - Email via SendGrid + * - A2A alert agent task + */ + private dispatchAlert(balance: number): void { + const msg = + `[ALERT] ${new Date().toISOString()} | ` + + `${CONFIG.TRANSFER_TOKEN} balance ${balance.toFixed(6)} ` + + `dropped below threshold ${CONFIG.ALERT_THRESHOLD}`; + console.error("\n" + "!".repeat(60)); + console.error(msg); + console.error("!".repeat(60) + "\n"); + // TODO: replace console.error with your notification integration + } + + // ------------------------------------------------------------------------- + // Orchestrator — wires all steps together + // ------------------------------------------------------------------------- + + private async executeWorkflow(): Promise { + const log: ExecutionLog = { + runId: `run-${this.runCount}-${Date.now()}`, + startedAt: new Date().toISOString(), + steps: [], + }; + + // Step 1: fetch balance + const balance = await this.fetchBalance(log); + if (balance === null) { + log.completedAt = new Date().toISOString(); + log.summary = "Aborted: could not fetch balance"; + return log; + } + + // Step 5 (alert) runs regardless of transfer outcome + this.checkAlert(balance, log); + + // Step 2: validate condition + const conditionMet = this.validateCondition(balance, log); + if (!conditionMet) { + log.completedAt = new Date().toISOString(); + log.summary = "Skipped: transfer condition not met"; + return log; + } + + // Step 3: execute transfer + const txHash = await this.executeTransfer(log); + if (!txHash) { + log.completedAt = new Date().toISOString(); + log.summary = "Failed: transfer did not complete"; + return log; + } + + // Step 4: downstream hook + await this.onPostTransfer(txHash, log); + + log.completedAt = new Date().toISOString(); + log.summary = `Transfer complete. tx=${txHash}`; + return log; + } + + // ------------------------------------------------------------------------- + // Logging + // ------------------------------------------------------------------------- + + private printLog(log: ExecutionLog): void { + const icons: Record = { + ok: "✅", + skipped: "⏭️ ", + error: "❌", + alert: "🚨", + }; + + console.log(`\n── Run #${this.runCount} [${log.runId}] ──────────────────────`); + for (const s of log.steps) { + console.log(` ${icons[s.status]} [${s.step}] ${s.detail}`); + if (s.data) { + for (const [k, v] of Object.entries(s.data)) { + console.log(` ${k}: ${v}`); + } + } + } + console.log(` 📋 Summary: ${log.summary}`); + console.log(` ⏱ ${log.startedAt} → ${log.completedAt}`); + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + if (!CONFIG.ACCOUNT_ADDRESS || !CONFIG.PRIVATE_KEY) { + console.error("❌ Missing required environment variables."); + console.error(" Set STARKNET_ACCOUNT_ADDRESS and STARKNET_PRIVATE_KEY in .env"); + process.exit(1); + } + + const agent = new TransferAgent(); + + process.on("SIGINT", () => { + agent.stop(); + process.exit(0); + }); + + await agent.start(); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error("Fatal:", err); + process.exit(1); + }); +} + +export { TransferAgent }; +export type { ExecutionLog, StepResult }; diff --git a/Cairo_Test/transfer-agent/package.json b/Cairo_Test/transfer-agent/package.json new file mode 100644 index 0000000..2c0fd7d --- /dev/null +++ b/Cairo_Test/transfer-agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "transfer-agent-example", + "version": "0.1.0", + "description": "Composable autonomous transfer agent with balance monitoring, conditional transfers, and alerts", + "private": true, + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "starknet": "^9.4.2", + "@avnu/avnu-sdk": "^4.0.1", + "dotenv": "^17.3.1" + }, + "devDependencies": { + "tsx": "^4.0.0", + "typescript": "^5.9.0" + } +} diff --git a/Cairo_Test/transfer-agent/send-once.ts b/Cairo_Test/transfer-agent/send-once.ts new file mode 100644 index 0000000..0a35740 --- /dev/null +++ b/Cairo_Test/transfer-agent/send-once.ts @@ -0,0 +1,146 @@ +/** + * One-shot transfer script + * Runs the workflow once and exits + */ + +import dotenv from "dotenv"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { + Account, + RpcProvider, + Contract, + CallData, + cairo, +} from "starknet"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: join(__dirname, ".env") }); + +const CONFIG = { + RPC_URL: process.env.STARKNET_RPC_URL!, + ACCOUNT_ADDRESS: process.env.STARKNET_ACCOUNT_ADDRESS!, + PRIVATE_KEY: process.env.STARKNET_PRIVATE_KEY!, + TRANSFER_TOKEN: process.env.TRANSFER_TOKEN || "STRK", + TRANSFER_AMOUNT: process.env.TRANSFER_AMOUNT || "10", + TRANSFER_RECIPIENT: process.env.TRANSFER_RECIPIENT!, +}; + +const TOKEN_ADDRESSES: Record = { + ETH: "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + STRK: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + USDC: "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8", + USDT: "0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8", +}; + +const TOKEN_DECIMALS: Record = { + ETH: 18, + STRK: 18, + USDC: 6, + USDT: 6, +}; + +const ERC20_ABI = [ + { + type: "interface", + name: "openzeppelin::token::erc20::interface::IERC20", + items: [ + { + type: "function", + name: "balance_of", + inputs: [ + { + name: "account", + type: "core::starknet::contract_address::ContractAddress", + }, + ], + outputs: [{ type: "core::integer::u256" }], + state_mutability: "view", + }, + { + type: "function", + name: "transfer", + inputs: [ + { + name: "recipient", + type: "core::starknet::contract_address::ContractAddress", + }, + { name: "amount", type: "core::integer::u256" }, + ], + outputs: [{ type: "core::bool" }], + state_mutability: "external", + }, + ], + }, +]; + +async function main() { + console.log("=".repeat(60)); + console.log(" One-Shot Transfer"); + console.log("=".repeat(60)); + console.log(` From : ${CONFIG.ACCOUNT_ADDRESS}`); + console.log(` To : ${CONFIG.TRANSFER_RECIPIENT}`); + console.log(` Amount : ${CONFIG.TRANSFER_AMOUNT} ${CONFIG.TRANSFER_TOKEN}`); + console.log(` RPC : ${CONFIG.RPC_URL}`); + console.log("=".repeat(60) + "\n"); + + const provider = new RpcProvider({ nodeUrl: CONFIG.RPC_URL }); + const account = new Account({ + provider, + address: CONFIG.ACCOUNT_ADDRESS, + signer: CONFIG.PRIVATE_KEY, + }); + + // Check balance + console.log("📊 Checking balance..."); + const tokenAddress = TOKEN_ADDRESSES[CONFIG.TRANSFER_TOKEN]; + const decimals = TOKEN_DECIMALS[CONFIG.TRANSFER_TOKEN]; + + const contract = new Contract({ + abi: ERC20_ABI, + address: tokenAddress, + providerOrAccount: provider, + }); + + const raw = await contract.balance_of(account.address); + const rawBigInt: bigint = typeof raw === "bigint" ? raw : BigInt(raw.toString()); + const balance = Number(rawBigInt) / 10 ** decimals; + + console.log(` Balance: ${balance.toFixed(6)} ${CONFIG.TRANSFER_TOKEN}\n`); + + const transferAmount = parseFloat(CONFIG.TRANSFER_AMOUNT); + if (balance < transferAmount) { + console.error(`❌ Insufficient balance. Need ${transferAmount}, have ${balance}`); + process.exit(1); + } + + // Execute transfer + console.log("🚀 Executing transfer..."); + const amountWei = BigInt(Math.floor(transferAmount * 10 ** decimals)); + + const calldata = CallData.compile({ + recipient: CONFIG.TRANSFER_RECIPIENT, + amount: cairo.uint256(amountWei), + }); + + const { transaction_hash } = await account.execute({ + contractAddress: tokenAddress, + entrypoint: "transfer", + calldata, + }); + + console.log(` Transaction submitted: ${transaction_hash}`); + console.log(" Waiting for acceptance..."); + + await provider.waitForTransaction(transaction_hash); + + console.log(`\n✅ Transfer complete!`); + console.log(` TX: ${transaction_hash}`); + console.log(` Amount: ${CONFIG.TRANSFER_AMOUNT} ${CONFIG.TRANSFER_TOKEN}`); + console.log(` To: ${CONFIG.TRANSFER_RECIPIENT}`); +} + +main().catch((err) => { + console.error("\n❌ Error:", err.message); + process.exit(1); +});