From 328b450b7de1ec0d5fca17867187eb94d8153348 Mon Sep 17 00:00:00 2001 From: Yusuf Habib <109147010+manlikeHB@users.noreply.github.com> Date: Wed, 28 Aug 2024 12:40:01 +0100 Subject: [PATCH] Cairo contract to mint aBTC (#64) * - Impl: mint_by_token, withdraw_coin_by_token and set_token_permitted - Fix IERC20MintableDispatcher * add vault test * Add test for mint_by_token * Add test for withdraw_coin_by_token * claen up * - impl: user deposit state - emit and write test for events * Add more test cases * Impl: Access control on erc20 mintable token * fix typo --- onchain/cairo/src/defi/vault.cairo | 139 +++++++--- .../cairo/src/interfaces/erc20_mintable.cairo | 4 + onchain/cairo/src/interfaces/vault.cairo | 5 +- onchain/cairo/src/lib.cairo | 1 + onchain/cairo/src/tests/vault_tests.cairo | 242 ++++++++++++++++++ onchain/cairo/src/tokens/erc20_mintable.cairo | 63 ++++- 6 files changed, 418 insertions(+), 36 deletions(-) create mode 100644 onchain/cairo/src/tests/vault_tests.cairo diff --git a/onchain/cairo/src/defi/vault.cairo b/onchain/cairo/src/defi/vault.cairo index 9e237c31..5d86cced 100644 --- a/onchain/cairo/src/defi/vault.cairo +++ b/onchain/cairo/src/defi/vault.cairo @@ -4,13 +4,16 @@ use starknet::ContractAddress; // TODO // Create the as a Vault component #[starknet::contract] -mod Vault { +pub mod Vault { + use afk::interfaces::erc20_mintable::{IERC20MintableDispatcher, IERC20MintableDispatcherTrait}; use afk::interfaces::vault::{IERCVault}; - // use afk::interfaces::erc20_mintable::{IERC20Mintable}; + use afk::tokens::erc20::{ERC20, IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; use afk::types::constants::{MINTER_ROLE, ADMIN_ROLE}; + use core::num::traits::Zero; use openzeppelin::access::accesscontrol::AccessControlComponent; use openzeppelin::introspection::src5::SRC5Component; + use starknet::event::EventEmitter; use starknet::{ ContractAddress, get_caller_address, storage_access::StorageBaseAddress, @@ -28,15 +31,12 @@ mod Vault { AccessControlComponent::AccessControlImpl; impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; - // TODO Change interface of IERC20 Mintable - // Fix dispatcher - // use afk::tokens::erc20_mintable::{ IERC20MintableDispatcher, IERC20MintableDispatcherTrait}; - #[storage] struct Storage { token_address: ContractAddress, is_mintable_paused: bool, token_permitted: LegacyMap, + is_token_permitted: LegacyMap, deposit_by_user: LegacyMap, deposit_by_user_by_token: LegacyMap::<(ContractAddress, ContractAddress), DepositUser>, #[substorage(v0)] @@ -59,7 +59,7 @@ mod Vault { #[event] #[derive(Drop, starknet::Event)] - enum Event { + pub enum Event { MintDepositEvent: MintDepositEvent, WithdrawDepositEvent: WithdrawDepositEvent, #[flat] @@ -76,22 +76,56 @@ mod Vault { // Used the specify ratio. Burn the token. Check the pooling withdraw fn mint_by_token(ref self: ContractState, token_address: ContractAddress, amount: u256) { let caller = get_caller_address(); - // Check if token valid - - // Sent token to deposit - - // let token_deposited= IERC20MintableDispatcher{ token_address}; - // token_deposited.transfer_from(caller, get_contract_address, amount); - - // Mint token and send it to the receiver - - // let token_mintable= IERC20MintableDispatcher{ token_address}; - - // Calculate the ratio if 1:1, less or more - // let amount_ratio=1; - // // let ratio =; - // token_mintable.mint(caller, amount_ratio); + // Check if token valid + assert(self.is_token_permitted(token_address), 'Non permited token'); + + // Sent token to deposit + let token_deposited = IERC20Dispatcher { contract_address: token_address }; + token_deposited.approve(caller, amount); + token_deposited.transfer_from(caller, get_contract_address(), amount); + + // Mint token and send it to the receiver + let token_mintable = IERC20MintableDispatcher { + contract_address: self.token_address.read() + }; + let _token = self.token_permitted.read(token_address); + + //TODO Calculate the ratio if 1:1, less or more + // let amount_ratio = token.ratio_mint * amount; + + token_mintable.mint(caller, amount); + + // update user deposit state + let mut old_deposit_user = self.deposit_by_user.read(caller); + + let mut deposit_user = old_deposit_user.clone(); + if old_deposit_user.token_address.is_zero() { + deposit_user = + DepositUser { + token_address: token_address, + deposited: amount, + minted: amount, + withdraw: 0, + }; + } else { + deposit_user.deposited += amount; + deposit_user.minted += amount; + } + + self.deposit_by_user.write(caller, deposit_user); + self.deposit_by_user_by_token.write((caller, token_address), deposit_user); + + // emit event + self + .emit( + MintDepositEvent { + caller: caller, + token_deposited: token_address, + amount_deposit: amount, + mint_receive: amount + } + ); } // Withdraw a coin @@ -101,23 +135,68 @@ mod Vault { ref self: ContractState, token_address: ContractAddress, amount: u256 ) { let caller = get_caller_address(); - // Check if token valid - - // Receive/burn token minted - - // Resend amount of coin deposit by user - + // Check if token valid + assert(self.is_token_permitted(token_address), 'Non permited token'); + + // Receive/burn token minted + let token_mintable = IERC20MintableDispatcher { + contract_address: self.token_address.read() + }; + token_mintable.burn(caller, amount); + + // Resend amount of coin deposit by user + let token_deposited = IERC20Dispatcher { contract_address: token_address }; + //TODO calculate ratio + // let amount_ratio = amount / self.token_permitted.read(token_address).ratio_mint; + token_deposited.transfer(caller, amount); + + // update user withdraw state + let mut deposit_user = self.deposit_by_user.read(caller); + + deposit_user.withdraw += amount; + + self.deposit_by_user.write(caller, deposit_user); + self.deposit_by_user_by_token.write((caller, token_address), deposit_user); + + // emit event + self + .emit( + WithdrawDepositEvent { + caller: caller, + token_deposited: self.token_address.read(), + amount_deposit: amount, + mint_receive: amount, + mint_to_get_after_poolin: 0, + pooling_interval: self.token_permitted.read(token_address).pooling_timestamp + } + ); } // Set token permitted fn set_token_permitted( ref self: ContractState, token_address: ContractAddress, - ratio: u256, + // ratio: u256, ratio_mint: u256, is_available: bool, pooling_timestamp: u64 - ) {} + ) { + self.accesscontrol.assert_only_role(ADMIN_ROLE); + let token_permitted = TokenPermitted { + token_address, ratio_mint, is_available, pooling_timestamp, + }; + self.token_permitted.write(token_address, token_permitted); + self.is_token_permitted.write(token_address, true); + } + + fn is_token_permitted(ref self: ContractState, token_address: ContractAddress,) -> bool { + self.is_token_permitted.read(token_address) + } + + fn get_token_ratio(ref self: ContractState, token_address: ContractAddress) -> u256 { + assert(self.is_token_permitted(token_address), 'Non permited token'); + self.token_permitted.read(token_address).ratio_mint + } } // Admin // Add OPERATOR role to the Vault escrow diff --git a/onchain/cairo/src/interfaces/erc20_mintable.cairo b/onchain/cairo/src/interfaces/erc20_mintable.cairo index ef4027b6..82b54cab 100644 --- a/onchain/cairo/src/interfaces/erc20_mintable.cairo +++ b/onchain/cairo/src/interfaces/erc20_mintable.cairo @@ -3,4 +3,8 @@ use starknet::ContractAddress; #[starknet::interface] pub trait IERC20Mintable { fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256); + fn burn(ref self: TContractState, recipient: ContractAddress, amount: u256); + fn set_role( + ref self: TContractState, recipient: ContractAddress, role: felt252, is_enable: bool + ); } diff --git a/onchain/cairo/src/interfaces/vault.cairo b/onchain/cairo/src/interfaces/vault.cairo index b159883f..7a95c570 100644 --- a/onchain/cairo/src/interfaces/vault.cairo +++ b/onchain/cairo/src/interfaces/vault.cairo @@ -7,14 +7,17 @@ pub trait IERCVault { fn withdraw_coin_by_token( ref self: TContractState, token_address: ContractAddress, amount: u256 ); + fn is_token_permitted(ref self: TContractState, token_address: ContractAddress) -> bool; fn set_token_permitted( ref self: TContractState, token_address: ContractAddress, - ratio: u256, + // ratio: u256, ratio_mint: u256, is_available: bool, pooling_timestamp: u64 ); + + fn get_token_ratio(ref self: TContractState, token_address: ContractAddress) -> u256; } diff --git a/onchain/cairo/src/lib.cairo b/onchain/cairo/src/lib.cairo index 0c70715f..2cce3149 100644 --- a/onchain/cairo/src/lib.cairo +++ b/onchain/cairo/src/lib.cairo @@ -52,5 +52,6 @@ pub mod tests { pub mod keys_tests; pub mod launchpad_tests; pub mod tap_tests; + pub mod vault_tests; } diff --git a/onchain/cairo/src/tests/vault_tests.cairo b/onchain/cairo/src/tests/vault_tests.cairo new file mode 100644 index 00000000..29ee7f60 --- /dev/null +++ b/onchain/cairo/src/tests/vault_tests.cairo @@ -0,0 +1,242 @@ +#[cfg(test)] +mod vault_test { + use afk::defi::vault::Vault::Event; + use afk::interfaces::erc20_mintable::{IERC20MintableDispatcher, IERC20MintableDispatcherTrait}; + use afk::interfaces::vault::{IERCVault, IERCVaultDispatcher, IERCVaultDispatcherTrait}; + use afk::tokens::erc20::{ERC20, IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; + use afk::types::defi_types::{ + TokenPermitted, DepositUser, MintDepositEvent, WithdrawDepositEvent + }; + + use snforge_std::{ + declare, ContractClass, ContractClassTrait, start_cheat_caller_address, + stop_cheat_caller_address, spy_events, EventSpy, SpyOn, EventAssertions + }; + use starknet::ContractAddress; + + fn ADMIN() -> ContractAddress { + 123.try_into().unwrap() + } + + fn CALLER() -> ContractAddress { + 5.try_into().unwrap() + } + + fn NAME(name: felt252) -> felt252 { + 'name'.try_into().unwrap() + } + + fn SYMBOL(symbol: felt252) -> felt252 { + 'symbol'.try_into().unwrap() + } + + const MINTER_ROLE: felt252 = selector!("MINTER_ROLE"); + + fn setup() -> (IERCVaultDispatcher, IERC20Dispatcher, IERC20Dispatcher,) { + let erc20_mintable_class = declare("ERC20Mintable").unwrap(); + let erc20_class = declare("ERC20").unwrap(); + + let wbtc_dispathcer = deploy_erc20( + erc20_class, 'wBTC token', 'wBTC', 100_000_000_u256, ADMIN(), + ); + let abtc_dispathcer = deploy_erc20_mint( + erc20_mintable_class, "aBTC token", "aBTC", ADMIN(), 100_000_000_u256, + ); + + let vault_class = declare("Vault").unwrap(); + + let mut calldata = array![abtc_dispathcer.contract_address.into()]; + ADMIN().serialize(ref calldata); + let (vault_address, _) = vault_class.deploy(@calldata).unwrap(); + + let vaultDispatcher = IERCVaultDispatcher { contract_address: vault_address }; + + // set minter role in erc20 mintable token + let abtc_mintable_dispathcer = IERC20MintableDispatcher { + contract_address: abtc_dispathcer.contract_address + }; + + start_cheat_caller_address(abtc_dispathcer.contract_address, ADMIN()); + abtc_mintable_dispathcer.set_role(vaultDispatcher.contract_address, MINTER_ROLE, true); + stop_cheat_caller_address(abtc_dispathcer.contract_address); + + (vaultDispatcher, wbtc_dispathcer, abtc_dispathcer,) + } + + fn deploy_erc20_mint( + class: ContractClass, + name: ByteArray, + symbol: ByteArray, + owner: ContractAddress, + initial_supply: u256, + ) -> IERC20Dispatcher { + let mut calldata: Array = ArrayTrait::new(); + + name.serialize(ref calldata); + symbol.serialize(ref calldata); + owner.serialize(ref calldata); + initial_supply.serialize(ref calldata); + + let (contract_address, _) = class.deploy(@calldata).unwrap(); + + IERC20Dispatcher { contract_address } + } + + fn deploy_erc20( + class: ContractClass, + name: felt252, + symbol: felt252, + initial_supply: u256, + recipient: ContractAddress + ) -> IERC20Dispatcher { + let mut calldata = array![]; + + name.serialize(ref calldata); + symbol.serialize(ref calldata); + initial_supply.serialize(ref calldata); + recipient.serialize(ref calldata); + 18_u8.serialize(ref calldata); + + let (contract_address, _) = class.deploy(@calldata).unwrap(); + + IERC20Dispatcher { contract_address } + } + + #[test] + fn test_mint_by_token() { + let (vault_dispatcher, wbtc_dispatcher, abtc_dispatcher) = setup(); + let mut spy = spy_events(SpyOn::One(vault_dispatcher.contract_address)); + let amount = 200; + + // set permited token + start_cheat_caller_address(vault_dispatcher.contract_address, ADMIN()); + vault_dispatcher.set_token_permitted(wbtc_dispatcher.contract_address, 2_u256, true, 1_64); + + // transfer tokens to caller + start_cheat_caller_address(wbtc_dispatcher.contract_address, ADMIN()); + wbtc_dispatcher.transfer(CALLER(), 400); + stop_cheat_caller_address(wbtc_dispatcher.contract_address); + + let _caller_initial_balance = wbtc_dispatcher.balance_of(CALLER()); + + // get allowance + start_cheat_caller_address(wbtc_dispatcher.contract_address, CALLER()); + wbtc_dispatcher.approve(vault_dispatcher.contract_address, amount); + stop_cheat_caller_address(wbtc_dispatcher.contract_address); + + start_cheat_caller_address(vault_dispatcher.contract_address, CALLER()); + vault_dispatcher.mint_by_token(wbtc_dispatcher.contract_address, amount); + stop_cheat_caller_address(vault_dispatcher.contract_address); + + assert( + wbtc_dispatcher.balance_of(vault_dispatcher.contract_address) == amount, 'wrong balance' + ); + // assert( + // wbtc_dispatcher.balance_of(CALLER()) == caller_initial_balance - amount, 'wrong balance' + // ); + + let _ratio = vault_dispatcher.get_token_ratio(wbtc_dispatcher.contract_address); + assert(abtc_dispatcher.balance_of(CALLER()) == amount, 'wrong balance'); + + // withdraw coin by token + let caller_init_aBTC_balance = abtc_dispatcher.balance_of(CALLER()); + let caller_init_wBTC_balance = wbtc_dispatcher.balance_of(CALLER()); + + start_cheat_caller_address(vault_dispatcher.contract_address, CALLER()); + vault_dispatcher.withdraw_coin_by_token(wbtc_dispatcher.contract_address, amount); + stop_cheat_caller_address(vault_dispatcher.contract_address); + + assert( + abtc_dispatcher.balance_of(CALLER()) == caller_init_aBTC_balance - amount, + 'wrong balance' + ); + assert( + wbtc_dispatcher.balance_of(CALLER()) == caller_init_wBTC_balance + amount, + 'wrong balance' + ); + + let expected_deposit_event = Event::MintDepositEvent( + MintDepositEvent { + caller: CALLER(), + token_deposited: wbtc_dispatcher.contract_address, + amount_deposit: amount, + mint_receive: amount, + } + ); + + let expected_withdraw_event = Event::WithdrawDepositEvent( + WithdrawDepositEvent { + caller: CALLER(), + token_deposited: abtc_dispatcher.contract_address, + amount_deposit: amount, + mint_receive: amount, + mint_to_get_after_poolin: 0, + pooling_interval: 1_64 + } + ); + + spy + .assert_emitted( + @array![ + (vault_dispatcher.contract_address, expected_deposit_event), + (vault_dispatcher.contract_address, expected_withdraw_event) + ] + ); + } + + #[test] + #[should_panic(expected: ('Non permited token',))] + fn test_mint_by_token_with_non_permitted_token() { + let (vault_dispatcher, wbtc_dispatcher, _,) = setup(); + + start_cheat_caller_address(vault_dispatcher.contract_address, CALLER()); + vault_dispatcher.mint_by_token(wbtc_dispatcher.contract_address, 200); + } + + #[test] + #[should_panic(expected: ('Non permited token',))] + fn test_withdraw_coin_by_token_with_non_permitted_token() { + let (vault_dispatcher, wbtc_dispatcher, _,) = setup(); + + start_cheat_caller_address(vault_dispatcher.contract_address, CALLER()); + vault_dispatcher.withdraw_coin_by_token(wbtc_dispatcher.contract_address, 200); + } + + #[test] + fn test_set_token_permitted() { + let (vault_dispatcher, wbtc_dispatcher, _,) = setup(); + + start_cheat_caller_address(vault_dispatcher.contract_address, ADMIN()); + vault_dispatcher.set_token_permitted(wbtc_dispatcher.contract_address, 2_u256, true, 1_64); + + assert( + vault_dispatcher.is_token_permitted(wbtc_dispatcher.contract_address), + 'token should be permitted' + ); + stop_cheat_caller_address(vault_dispatcher.contract_address); + } + + #[test] + #[should_panic(expected: ('Non permited token',))] + fn test_get_token_ratio_with_non_permitted_token() { + let (vault_dispatcher, _, abtc_dispatcher,) = setup(); + + start_cheat_caller_address(vault_dispatcher.contract_address, CALLER()); + vault_dispatcher.get_token_ratio(abtc_dispatcher.contract_address); + } + + #[test] + fn test_get_token_ratio() { + let (vault_dispatcher, wbtc_dispatcher, _,) = setup(); + let ratio = 5; + + start_cheat_caller_address(vault_dispatcher.contract_address, ADMIN()); + vault_dispatcher.set_token_permitted(wbtc_dispatcher.contract_address, ratio, true, 1_64); + stop_cheat_caller_address(vault_dispatcher.contract_address); + + start_cheat_caller_address(vault_dispatcher.contract_address, CALLER()); + let res = vault_dispatcher.get_token_ratio(wbtc_dispatcher.contract_address); + + assert(res == ratio, 'wrong ratio'); + } +} diff --git a/onchain/cairo/src/tokens/erc20_mintable.cairo b/onchain/cairo/src/tokens/erc20_mintable.cairo index 6857db81..75c0d815 100644 --- a/onchain/cairo/src/tokens/erc20_mintable.cairo +++ b/onchain/cairo/src/tokens/erc20_mintable.cairo @@ -2,10 +2,11 @@ use afk::interfaces::erc20_mintable::{IERC20Mintable}; use starknet::ContractAddress; #[starknet::contract] -mod ERC20Mintable { - use openzeppelin::access::accesscontrol::AccessControlComponent; +pub mod ERC20Mintable { use openzeppelin::access::accesscontrol::interface::IAccessControl; + use openzeppelin::access::accesscontrol::{AccessControlComponent}; use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; use starknet::ContractAddress; @@ -15,6 +16,9 @@ mod ERC20Mintable { component!(path: ERC20Component, storage: erc20, event: ERC20Event); component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + // Ownable @@ -24,6 +28,12 @@ mod ERC20Mintable { impl OwnableCamelOnlyImpl = OwnableComponent::OwnableCamelOnlyImpl; impl InternalImplOwnable = OwnableComponent::InternalImpl; + #[abi(embed_v0)] + impl AccessControlImpl = + AccessControlComponent::AccessControlImpl; + impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; // ERC20 #[abi(embed_v0)] @@ -49,7 +59,11 @@ mod ERC20Mintable { #[substorage(v0)] erc20: ERC20Component::Storage, #[substorage(v0)] - ownable: OwnableComponent::Storage + ownable: OwnableComponent::Storage, + #[substorage(v0)] + accesscontrol: AccessControlComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, } #[event] @@ -58,7 +72,11 @@ mod ERC20Mintable { #[flat] ERC20Event: ERC20Component::Event, #[flat] - OwnableEvent: OwnableComponent::Event + OwnableEvent: OwnableComponent::Event, + #[flat] + AccessControlEvent: AccessControlComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, } #[constructor] @@ -72,6 +90,8 @@ mod ERC20Mintable { self.ownable.initializer(owner); self.erc20.initializer(name, symbol); self.erc20._mint(owner, initial_supply); + self.accesscontrol.initializer(); + self.accesscontrol._grant_role(ADMIN_ROLE, owner); } // #[external(v0)] @@ -83,8 +103,41 @@ mod ERC20Mintable { #[abi(embed_v0)] impl IERC20MintableImpl of super::IERC20Mintable { fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { - self.ownable.assert_only_owner(); + self.accesscontrol.assert_only_role(MINTER_ROLE); self.erc20._mint(recipient, amount); } + + fn burn(ref self: ContractState, recipient: ContractAddress, amount: u256) { + self.accesscontrol.assert_only_role(MINTER_ROLE); + self.erc20._burn(recipient, amount); + } + fn set_role( + ref self: ContractState, recipient: ContractAddress, role: felt252, is_enable: bool + ) { + self._set_role(recipient, role, is_enable); + } + } + + // Admin + //Add OPERATOR role to the Vault escrow + // #[external(v0)] + #[generate_trait] + impl PrivateImpl of PrivateTrait { + fn _set_role( + ref self: ContractState, recipient: ContractAddress, role: felt252, is_enable: bool + ) { + self.accesscontrol.assert_only_role(ADMIN_ROLE); + assert!( + role == ADMIN_ROLE + || role == MINTER_ROLE // Think and Add others roles needed on the protocol + , + "role not enable" + ); + if is_enable { + self.accesscontrol._grant_role(role, recipient); + } else { + self.accesscontrol._revoke_role(role, recipient); + } + } } }