From 1ebed30a441c88081801f794fcb42415bb079fb1 Mon Sep 17 00:00:00 2001 From: Ege Caner <67071243+EgeCaner@users.noreply.github.com> Date: Fri, 13 Sep 2024 01:11:41 +0300 Subject: [PATCH] Hyp_Erc20_Collateral_Vault_Deposit tests (#110) * overrides for `_handle` * fmt * make test pass * fmt&silence failing test template * ERC4626 draft * ERC4626 implementations * getters * vault collateral rebase func * Fix initializer and embedded missing components * comment for upgradeable impls * remove outdated import * merge 'hyperlane/feat-token-extensions' into 'feat-token-extensions' * fix hyp_erc_721_setup * fmt * Removed TODOS for muldiv Round Down is default * erc721 test deployment fix * using self instead of ERC20MixinImpl * u256 encode * hyp_xerc20_test * xerc20 test * removed unused import * ERC4626 Mocks * hyp_erc20_collateral_vault_deposit_test * fmt --- .../contracts/mocks/erc4626_component.cairo | 484 ++++++++++++++++++ cairo/src/contracts/mocks/erc4626_mock.cairo | 46 ++ .../mocks/erc4626_yield_sharing_mock.cairo | 228 +++++++++ .../hyp_erc20_collateral_vault_deposit.cairo | 12 +- .../token/extensions/hyp_xerc20_lockbox.cairo | 1 - .../contracts/token/interfaces/ierc4626.cairo | 9 +- cairo/src/lib.cairo | 7 + cairo/src/tests/token/hyp_erc20/common.cairo | 8 +- .../token/hyp_erc20/hyp_xerc20_test.cairo | 15 +- ..._erc20_collateral_vault_deposit_test.cairo | 260 ++++++++++ 10 files changed, 1056 insertions(+), 14 deletions(-) create mode 100644 cairo/src/contracts/mocks/erc4626_component.cairo create mode 100644 cairo/src/contracts/mocks/erc4626_mock.cairo create mode 100644 cairo/src/contracts/mocks/erc4626_yield_sharing_mock.cairo create mode 100644 cairo/src/tests/token/vault_extensions/hyp_erc20_collateral_vault_deposit_test.cairo diff --git a/cairo/src/contracts/mocks/erc4626_component.cairo b/cairo/src/contracts/mocks/erc4626_component.cairo new file mode 100644 index 0000000..e8026c3 --- /dev/null +++ b/cairo/src/contracts/mocks/erc4626_component.cairo @@ -0,0 +1,484 @@ +//! Modified from {https://github.com/0xHashstack/hashstack_contracts/blob/main/src/token/erc4626/erc4626component.cairo} +use starknet::ContractAddress; + +#[starknet::component] +pub mod ERC4626Component { + use core::integer::BoundedInt; + + use hyperlane_starknet::contracts::token::interfaces::ierc4626::{ + IERC4626, IERC4626Camel, IERC4626Metadata + }; + use openzeppelin::introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait}; + use openzeppelin::introspection::src5::{ + SRC5Component, SRC5Component::SRC5Impl, SRC5Component::InternalTrait as SRC5INternalTrait + }; + use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait; + use openzeppelin::token::erc20::interface::{ + IERC20, IERC20Metadata, ERC20ABIDispatcher, ERC20ABIDispatcherTrait, + }; + use openzeppelin::token::erc20::{ + ERC20Component, ERC20HooksEmptyImpl, ERC20Component::Errors as ERC20Errors + }; + use starknet::{ContractAddress, get_caller_address, get_contract_address}; + + #[storage] + struct Storage { + ERC4626_asset: ContractAddress, + ERC4626_underlying_decimals: u8, + ERC4626_offset: u8, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Deposit: Deposit, + Withdraw: Withdraw, + } + + #[derive(Drop, starknet::Event)] + struct Deposit { + #[key] + sender: ContractAddress, + #[key] + owner: ContractAddress, + assets: u256, + shares: u256 + } + + #[derive(Drop, starknet::Event)] + struct Withdraw { + #[key] + sender: ContractAddress, + #[key] + receiver: ContractAddress, + #[key] + owner: ContractAddress, + assets: u256, + shares: u256 + } + + mod Errors { + const EXCEEDED_MAX_DEPOSIT: felt252 = 'ERC4626: exceeded max deposit'; + const EXCEEDED_MAX_MINT: felt252 = 'ERC4626: exceeded max mint'; + const EXCEEDED_MAX_REDEEM: felt252 = 'ERC4626: exceeded max redeem'; + const EXCEEDED_MAX_WITHDRAW: felt252 = 'ERC4626: exceeded max withdraw'; + } + + pub trait ERC4626HooksTrait { + fn before_deposit( + ref self: ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + assets: u256, + shares: u256 + ); + fn after_deposit( + ref self: ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + assets: u256, + shares: u256 + ); + + fn before_withdraw( + ref self: ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + owner: ContractAddress, + assets: u256, + shares: u256 + ); + + fn after_withdraw( + ref self: ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + owner: ContractAddress, + assets: u256, + shares: u256 + ); + } + + #[embeddable_as(ERC4626Impl)] + pub impl ERC4626< + TContractState, + +HasComponent, + impl ERC20: ERC20Component::HasComponent, + +ERC4626HooksTrait, + +SRC5Component::HasComponent, + +Drop + > of IERC4626> { + fn name(self: @ComponentState) -> ByteArray { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.name() + } + + fn symbol(self: @ComponentState) -> ByteArray { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.symbol() + } + + fn decimals(self: @ComponentState) -> u8 { + self.ERC4626_underlying_decimals.read() + self.ERC4626_offset.read() + } + + fn total_supply(self: @ComponentState) -> u256 { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.total_supply() + } + + fn balance_of(self: @ComponentState, account: ContractAddress) -> u256 { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.balance_of(account) + } + + fn allowance( + self: @ComponentState, owner: ContractAddress, spender: ContractAddress + ) -> u256 { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.allowance(owner, spender) + } + + fn transfer( + ref self: ComponentState, recipient: ContractAddress, amount: u256 + ) -> bool { + let mut erc20_comp_mut = get_dep_component_mut!(ref self, ERC20); + erc20_comp_mut.transfer(recipient, amount) + } + + fn transfer_from( + ref self: ComponentState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256 + ) -> bool { + let mut erc20_comp_mut = get_dep_component_mut!(ref self, ERC20); + erc20_comp_mut.transfer_from(sender, recipient, amount) + } + + fn approve( + ref self: ComponentState, spender: ContractAddress, amount: u256 + ) -> bool { + let mut erc20_comp_mut = get_dep_component_mut!(ref self, ERC20); + erc20_comp_mut.approve(spender, amount) + } + + fn asset(self: @ComponentState) -> ContractAddress { + self.ERC4626_asset.read() + } + + fn convert_to_assets(self: @ComponentState, shares: u256) -> u256 { + self._convert_to_assets(shares) + } + + fn convert_to_shares(self: @ComponentState, assets: u256) -> u256 { + self._convert_to_shares(assets) + } + + fn deposit( + ref self: ComponentState, assets: u256, receiver: ContractAddress + ) -> u256 { + let caller = get_caller_address(); + let shares = self.preview_deposit(assets); + self._deposit(caller, receiver, assets, shares); + shares + } + + fn mint( + ref self: ComponentState, shares: u256, receiver: ContractAddress + ) -> u256 { + let caller = get_caller_address(); + let assets = self.preview_mint(shares); + self._deposit(caller, receiver, assets, shares); + assets + } + + fn preview_deposit(self: @ComponentState, assets: u256) -> u256 { + self._convert_to_shares(assets) + } + + fn preview_mint(self: @ComponentState, shares: u256) -> u256 { + self._convert_to_assets(shares) + } + + fn preview_redeem(self: @ComponentState, shares: u256) -> u256 { + self._convert_to_assets(shares) + } + + fn preview_withdraw(self: @ComponentState, assets: u256) -> u256 { + self._convert_to_shares(assets) + } + + fn max_deposit(self: @ComponentState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn max_mint(self: @ComponentState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn max_redeem(self: @ComponentState, owner: ContractAddress) -> u256 { + let erc20 = get_dep_component!(self, ERC20); + erc20.balance_of(owner) + } + + fn max_withdraw(self: @ComponentState, owner: ContractAddress) -> u256 { + let erc20 = get_dep_component!(self, ERC20); + let balance = erc20.balance_of(owner); + self._convert_to_assets(balance) + } + + fn redeem( + ref self: ComponentState, + shares: u256, + receiver: ContractAddress, + owner: ContractAddress + ) -> u256 { + let caller = get_caller_address(); + let assets = self.preview_redeem(shares); + self._withdraw(caller, receiver, owner, assets, shares); + assets + } + + fn total_assets(self: @ComponentState) -> u256 { + let dispatcher = ERC20ABIDispatcher { contract_address: self.ERC4626_asset.read() }; + dispatcher.balance_of(get_contract_address()) + } + + fn withdraw( + ref self: ComponentState, + assets: u256, + receiver: ContractAddress, + owner: ContractAddress + ) -> u256 { + let caller = get_caller_address(); + let shares = self.preview_withdraw(assets); + self._withdraw(caller, receiver, owner, assets, shares); + + shares + } + } + + #[embeddable_as(ERC4626MetadataImpl)] + pub impl ERC4626Metadata< + TContractState, + +HasComponent, + impl ERC20: ERC20Component::HasComponent, + +SRC5Component::HasComponent, + +Drop + > of IERC4626Metadata> { + fn name(self: @ComponentState) -> ByteArray { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.name() + } + fn symbol(self: @ComponentState) -> ByteArray { + let erc20_comp = get_dep_component!(ref self, ERC20); + erc20_comp.symbol() + } + fn decimals(self: @ComponentState) -> u8 { + self.ERC4626_underlying_decimals.read() + self.ERC4626_offset.read() + } + } + + #[embeddable_as(ERC4626CamelImpl)] + pub impl ERC4626Camel< + TContractState, + +HasComponent, + +ERC20Component::HasComponent, + +SRC5Component::HasComponent, + +ERC4626HooksTrait, + +Drop + > of IERC4626Camel> { + fn totalSupply(self: @ComponentState) -> u256 { + self.total_supply() + } + fn balanceOf(self: @ComponentState, account: ContractAddress) -> u256 { + self.balance_of(account) + } + fn transferFrom( + ref self: ComponentState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256 + ) -> bool { + self.transfer_from(sender, recipient, amount) + } + + fn convertToAssets(self: @ComponentState, shares: u256) -> u256 { + self._convert_to_assets(shares) + } + + fn convertToShares(self: @ComponentState, assets: u256) -> u256 { + self._convert_to_shares(assets) + } + + fn previewDeposit(self: @ComponentState, assets: u256) -> u256 { + self._convert_to_shares(assets) + } + + fn previewMint(self: @ComponentState, shares: u256) -> u256 { + self._convert_to_assets(shares) + } + + fn previewRedeem(self: @ComponentState, shares: u256) -> u256 { + self._convert_to_assets(shares) + } + + fn previewWithdraw(self: @ComponentState, assets: u256) -> u256 { + self._convert_to_shares(assets) + } + + fn totalAssets(self: @ComponentState) -> u256 { + let dispatcher = ERC20ABIDispatcher { contract_address: self.ERC4626_asset.read() }; + dispatcher.balanceOf(get_contract_address()) + } + + fn maxDeposit(self: @ComponentState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn maxMint(self: @ComponentState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn maxRedeem(self: @ComponentState, owner: ContractAddress) -> u256 { + self.max_redeem(owner) + } + + fn maxWithdraw(self: @ComponentState, owner: ContractAddress) -> u256 { + self.max_withdraw(owner) + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + impl ERC20: ERC20Component::HasComponent, + +SRC5Component::HasComponent, + impl Hooks: ERC4626HooksTrait, + +Drop + > of InternalImplTrait { + fn initializer( + ref self: ComponentState, + asset: ContractAddress, + name: ByteArray, + symbol: ByteArray, + offset: u8 + ) { + let dispatcher = ERC20ABIDispatcher { contract_address: asset }; + self.ERC4626_offset.write(offset); + let decimals = dispatcher.decimals(); + let mut erc20_comp_mut = get_dep_component_mut!(ref self, ERC20); + erc20_comp_mut.initializer(name, symbol); + self.ERC4626_asset.write(asset); + self.ERC4626_underlying_decimals.write(decimals); + } + + fn _convert_to_assets(self: @ComponentState, shares: u256) -> u256 { + let supply: u256 = self.total_supply(); + if (supply == 0) { + shares + } else { + (shares * self.total_assets()) / supply + } + } + + fn _convert_to_shares(self: @ComponentState, assets: u256) -> u256 { + let supply: u256 = self.total_supply(); + if (assets == 0 || supply == 0) { + assets + } else { + (assets * supply) / self.total_assets() + } + } + + fn _deposit( + ref self: ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + assets: u256, + shares: u256 + ) { + Hooks::before_deposit(ref self, caller, receiver, assets, shares); + + let dispatcher = ERC20ABIDispatcher { contract_address: self.ERC4626_asset.read() }; + dispatcher.transfer_from(caller, get_contract_address(), assets); + let mut erc20_comp_mut = get_dep_component_mut!(ref self, ERC20); + erc20_comp_mut.mint(receiver, shares); + self.emit(Deposit { sender: caller, owner: receiver, assets, shares }); + + Hooks::after_deposit(ref self, caller, receiver, assets, shares); + } + + fn _withdraw( + ref self: ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + owner: ContractAddress, + assets: u256, + shares: u256 + ) { + Hooks::before_withdraw(ref self, caller, receiver, owner, assets, shares); + + let mut erc20_comp_mut = get_dep_component_mut!(ref self, ERC20); + if (caller != owner) { + let erc20_comp = get_dep_component!(@self, ERC20); + let allowance = erc20_comp.allowance(owner, caller); + if (allowance != BoundedInt::max()) { + assert(allowance >= shares, ERC20Errors::APPROVE_FROM_ZERO); + erc20_comp_mut.ERC20_allowances.write((owner, caller), allowance - shares); + } + } + + erc20_comp_mut.burn(owner, shares); + + let dispatcher = ERC20ABIDispatcher { contract_address: self.ERC4626_asset.read() }; + dispatcher.transfer(receiver, assets); + + self.emit(Withdraw { sender: caller, receiver, owner, assets, shares }); + + Hooks::before_withdraw(ref self, caller, receiver, owner, assets, shares); + } + + fn _decimals_offset(self: @ComponentState) -> u8 { + self.ERC4626_offset.read() + } + } +} + +pub impl ERC4626HooksEmptyImpl< + TContractState +> of ERC4626Component::ERC4626HooksTrait { + fn before_deposit( + ref self: ERC4626Component::ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + assets: u256, + shares: u256 + ) {} + fn after_deposit( + ref self: ERC4626Component::ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + assets: u256, + shares: u256 + ) {} + + fn before_withdraw( + ref self: ERC4626Component::ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + owner: ContractAddress, + assets: u256, + shares: u256 + ) {} + fn after_withdraw( + ref self: ERC4626Component::ComponentState, + caller: ContractAddress, + receiver: ContractAddress, + owner: ContractAddress, + assets: u256, + shares: u256 + ) {} +} diff --git a/cairo/src/contracts/mocks/erc4626_mock.cairo b/cairo/src/contracts/mocks/erc4626_mock.cairo new file mode 100644 index 0000000..e6caf8e --- /dev/null +++ b/cairo/src/contracts/mocks/erc4626_mock.cairo @@ -0,0 +1,46 @@ +#[starknet::contract] +mod ERC4626Mock { + use hyperlane_starknet::contracts::mocks::erc4626_component::{ + ERC4626Component, ERC4626HooksEmptyImpl + }; + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::token::erc20::ERC20Component; + use openzeppelin::token::erc20::interface::{IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{get_contract_address, ContractAddress}; + + component!(path: ERC4626Component, storage: erc4626, event: ERC4626Event); + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + #[abi(embed_v0)] + impl ERC4626Impl = ERC4626Component::ERC4626Impl; + impl ERC4626InternalImpl = ERC4626Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + erc4626: ERC4626Component::Storage, + #[substorage(v0)] + erc20: ERC20Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC4626Event: ERC4626Component::Event, + #[flat] + ERC20Event: ERC20Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, asset: ContractAddress, name: ByteArray, symbol: ByteArray, + ) { + self.erc4626.initializer(asset, name, symbol, 0); + } +} diff --git a/cairo/src/contracts/mocks/erc4626_yield_sharing_mock.cairo b/cairo/src/contracts/mocks/erc4626_yield_sharing_mock.cairo new file mode 100644 index 0000000..54003fa --- /dev/null +++ b/cairo/src/contracts/mocks/erc4626_yield_sharing_mock.cairo @@ -0,0 +1,228 @@ +#[starknet::interface] +trait IERC4626YieldSharing { + fn set_fee(ref self: TContractState, new_fee: u256); + fn get_claimable_fees(self: @TContractState) -> u256; +} + +#[starknet::contract] +mod ERC4626YieldSharingMock { + use core::integer::BoundedInt; + use hyperlane_starknet::contracts::libs::math; + use hyperlane_starknet::contracts::mocks::erc4626_component::{ + ERC4626Component, ERC4626HooksEmptyImpl + }; + use hyperlane_starknet::contracts::token::interfaces::ierc4626::IERC4626; + use openzeppelin::access::ownable::{OwnableComponent}; + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::token::erc20::ERC20Component; + use openzeppelin::token::erc20::interface::{IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{get_contract_address, get_caller_address, ContractAddress}; + + component!(path: ERC4626Component, storage: erc4626, event: ERC4626Event); + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + + impl ERC4626Impl = ERC4626Component::ERC4626Impl; + impl ERC4626InternalImpl = ERC4626Component::InternalImpl; + + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + // E18 + const SCALE: u256 = 1_000_000_000_000_000_000; + + #[storage] + struct Storage { + fee: u256, + accumulated_fees: u256, + last_vault_balance: u256, + #[substorage(v0)] + erc4626: ERC4626Component::Storage, + #[substorage(v0)] + erc20: ERC20Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + ownable: OwnableComponent::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC4626Event: ERC4626Component::Event, + #[flat] + ERC20Event: ERC20Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + OwnableEvent: OwnableComponent::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + asset: ContractAddress, + name: ByteArray, + symbol: ByteArray, + initial_fee: u256 + ) { + self.erc4626.initializer(asset, name, symbol, 0); + self.fee.write(initial_fee); + self.ownable.initializer(get_caller_address()); + } + + pub impl ERC4626YieldSharingImpl of super::IERC4626YieldSharing { + fn set_fee(ref self: ContractState, new_fee: u256) { + self.ownable.assert_only_owner(); + self.fee.write(new_fee); + } + + fn get_claimable_fees(self: @ContractState) -> u256 { + let new_vault_balance = IERC20Dispatcher { contract_address: self.erc4626.asset() } + .balance_of(get_contract_address()); + let last_vault_balance = self.last_vault_balance.read(); + if new_vault_balance <= last_vault_balance { + return self.accumulated_fees.read(); + } + + let new_yield = new_vault_balance - last_vault_balance; + let new_fees = math::mul_div(new_yield, self.fee.read(), SCALE); + + self.accumulated_fees.read() + new_fees + } + } + + pub impl ERC4626 of IERC4626 { + fn name(self: @ContractState) -> ByteArray { + self.erc4626.name() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.erc4626.symbol() + } + + fn decimals(self: @ContractState) -> u8 { + self.erc4626.decimals() + } + + fn total_supply(self: @ContractState) -> u256 { + self.erc4626.total_supply() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.erc4626.balance_of(account) + } + + fn allowance( + self: @ContractState, owner: ContractAddress, spender: ContractAddress + ) -> u256 { + self.erc4626.allowance(owner, spender) + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + self.erc4626.transfer(recipient, amount) + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256 + ) -> bool { + self.erc4626.transfer_from(sender, recipient, amount) + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + self.erc4626.approve(spender, amount) + } + + fn asset(self: @ContractState) -> ContractAddress { + self.erc4626.asset() + } + + fn convert_to_assets(self: @ContractState, shares: u256) -> u256 { + self.erc4626.convert_to_assets(shares) + } + + fn convert_to_shares(self: @ContractState, assets: u256) -> u256 { + self.erc4626.convert_to_shares(assets) + } + // Overriden + fn deposit(ref self: ContractState, assets: u256, receiver: ContractAddress) -> u256 { + let last_vault_balance = self.last_vault_balance.read(); + self.last_vault_balance.write(last_vault_balance + assets); + self.erc4626.deposit(assets, receiver) + } + + fn mint(ref self: ContractState, shares: u256, receiver: ContractAddress) -> u256 { + self.erc4626.mint(shares, receiver) + } + + fn preview_deposit(self: @ContractState, assets: u256) -> u256 { + self.erc4626.preview_deposit(assets) + } + + fn preview_mint(self: @ContractState, shares: u256) -> u256 { + self.erc4626.preview_mint(shares) + } + + fn preview_redeem(self: @ContractState, shares: u256) -> u256 { + self.erc4626.preview_redeem(shares) + } + + fn preview_withdraw(self: @ContractState, assets: u256) -> u256 { + self.erc4626.preview_withdraw(assets) + } + + fn max_deposit(self: @ContractState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn max_mint(self: @ContractState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn max_redeem(self: @ContractState, owner: ContractAddress) -> u256 { + self.erc4626.max_redeem(owner) + } + + fn max_withdraw(self: @ContractState, owner: ContractAddress) -> u256 { + self.erc4626.max_withdraw(owner) + } + // Overriden + fn redeem( + ref self: ContractState, shares: u256, receiver: ContractAddress, owner: ContractAddress + ) -> u256 { + self._accrue_yield(); + self.erc4626.redeem(shares, receiver, owner) + } + // Overriden + fn total_assets(self: @ContractState) -> u256 { + self.erc4626.total_assets() - self.get_claimable_fees() + } + + fn withdraw( + ref self: ContractState, assets: u256, receiver: ContractAddress, owner: ContractAddress + ) -> u256 { + self.erc4626.withdraw(assets, receiver, owner) + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _accrue_yield(ref self: ContractState) { + let new_vault_balance = IERC20Dispatcher { contract_address: self.erc4626.asset() } + .balance_of(get_contract_address()); + let last_vault_balance = self.last_vault_balance.read(); + if new_vault_balance > last_vault_balance { + let new_yield = new_vault_balance - last_vault_balance; + let new_fees = math::mul_div(new_yield, self.fee.read(), SCALE); + let accumulated_fees = self.accumulated_fees.read(); + self.accumulated_fees.write(accumulated_fees + new_fees); + self.last_vault_balance.write(new_vault_balance); + } + } + } +} diff --git a/cairo/src/contracts/token/extensions/hyp_erc20_collateral_vault_deposit.cairo b/cairo/src/contracts/token/extensions/hyp_erc20_collateral_vault_deposit.cairo index e177632..67a6a47 100644 --- a/cairo/src/contracts/token/extensions/hyp_erc20_collateral_vault_deposit.cairo +++ b/cairo/src/contracts/token/extensions/hyp_erc20_collateral_vault_deposit.cairo @@ -119,18 +119,20 @@ pub mod HypERC20CollateralVaultDeposit { mailbox: ContractAddress, vault: ContractAddress, owner: ContractAddress, - hook: Option, - interchain_security_module: Option + hook: ContractAddress, + interchain_security_module: ContractAddress ) { self.ownable.initializer(owner); - self.mailbox.initialize(mailbox, hook, interchain_security_module); + self + .mailbox + .initialize(mailbox, Option::Some(hook), Option::Some(interchain_security_module)); let vault_dispatcher = ERC4626ABIDispatcher { contract_address: vault }; let erc20 = vault_dispatcher.asset(); self.collateral.initialize(erc20); self.vault.write(vault_dispatcher); self.collateral.wrapped_token.read().approve(vault, BoundedInt::max()); } - + #[abi(embed_v0)] impl HypERC20CollateralVaultDepositImpl of super::IHypERC20CollateralVaultDeposit< ContractState > { @@ -195,7 +197,7 @@ pub mod HypERC20CollateralVaultDeposit { fn _withdraw_from_vault(ref self: ContractState, amount: u256, recipient: ContractAddress) { let asset_deposited = self.asset_deposited.read(); self.asset_deposited.write(asset_deposited - amount); - self.vault.read().withdraw(amount, recipient, starknet::get_caller_address()); + self.vault.read().withdraw(amount, recipient, starknet::get_contract_address()); } } } diff --git a/cairo/src/contracts/token/extensions/hyp_xerc20_lockbox.cairo b/cairo/src/contracts/token/extensions/hyp_xerc20_lockbox.cairo index 989dcfd..82fed52 100644 --- a/cairo/src/contracts/token/extensions/hyp_xerc20_lockbox.cairo +++ b/cairo/src/contracts/token/extensions/hyp_xerc20_lockbox.cairo @@ -72,7 +72,6 @@ pub mod HypXERC20Lockbox { // Upgradeable impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; - #[storage] struct Storage { #[substorage(v0)] diff --git a/cairo/src/contracts/token/interfaces/ierc4626.cairo b/cairo/src/contracts/token/interfaces/ierc4626.cairo index a09afac..84e2a1f 100644 --- a/cairo/src/contracts/token/interfaces/ierc4626.cairo +++ b/cairo/src/contracts/token/interfaces/ierc4626.cairo @@ -6,7 +6,7 @@ use starknet::ContractAddress; #[starknet::interface] pub trait IERC4626 { // ************************************ - // * IERC4626 + // * IERC20 // ************************************ fn total_supply(self: @TState) -> u256; fn balance_of(self: @TState, account: ContractAddress) -> u256; @@ -16,7 +16,12 @@ pub trait IERC4626 { ref self: TState, sender: ContractAddress, recipient: ContractAddress, amount: u256 ) -> bool; fn approve(ref self: TState, spender: ContractAddress, amount: u256) -> bool; - + // ************************************ + // * IERC20 metadata + // ************************************ + fn name(self: @TState) -> ByteArray; + fn symbol(self: @TState) -> ByteArray; + fn decimals(self: @TState) -> u8; // ************************************ // * IERC4626 // ************************************ diff --git a/cairo/src/lib.cairo b/cairo/src/lib.cairo index 1980df2..8de9c45 100644 --- a/cairo/src/lib.cairo +++ b/cairo/src/lib.cairo @@ -27,6 +27,9 @@ mod contracts { } pub mod mocks { pub mod enumerable_map_holder; + pub mod erc4626_component; + pub mod erc4626_mock; + pub mod erc4626_yield_sharing_mock; pub mod fee_hook; pub mod fee_token; pub mod hook; @@ -143,6 +146,10 @@ mod tests { pub mod hyp_erc721_test; pub mod hyp_erc721_uri_storage_test; } + + pub mod vault_extensions { + pub mod hyp_erc20_collateral_vault_deposit_test; + } } pub mod libs { pub mod test_enumerable_map; diff --git a/cairo/src/tests/token/hyp_erc20/common.cairo b/cairo/src/tests/token/hyp_erc20/common.cairo index 60f30ca..9db0b1c 100644 --- a/cairo/src/tests/token/hyp_erc20/common.cairo +++ b/cairo/src/tests/token/hyp_erc20/common.cairo @@ -319,9 +319,11 @@ pub fn handle_local_transfer(setup: @Setup, transfer_amount: u256) { stop_prank(CheatTarget::One((*setup).local_token.contract_address)); } -pub fn mint_and_approve(setup: @Setup, amount: u256, account: ContractAddress) { - (*setup).primary_token.mint(account, amount); - (*setup).primary_token.approve(account, amount); +pub fn mint_and_approve( + setup: @Setup, amount: u256, mint_to: ContractAddress, approve_to: ContractAddress +) { + (*setup).primary_token.mint(mint_to, amount); + (*setup).primary_token.approve(approve_to, amount); } pub fn set_custom_gas_config(setup: @Setup) { diff --git a/cairo/src/tests/token/hyp_erc20/hyp_xerc20_test.cairo b/cairo/src/tests/token/hyp_erc20/hyp_xerc20_test.cairo index 72f656c..fde5568 100644 --- a/cairo/src/tests/token/hyp_erc20/hyp_xerc20_test.cairo +++ b/cairo/src/tests/token/hyp_erc20/hyp_xerc20_test.cairo @@ -40,12 +40,21 @@ fn setup_xerc20() -> Setup { setup.local_token = IHypERC20TestDispatcher { contract_address: local_token }; println!("HypXERC20: {:?}", local_token); - enroll_local_router(@setup); - enroll_remote_router(@setup); - + setup + .local_token + .enroll_remote_router( + DESTINATION, + Into::::into(setup.remote_token.contract_address).into() + ); setup.primary_token.transfer(local_token, 1000 * E18); setup.primary_token.transfer(ALICE(), 1000 * E18); setup + .remote_token + .enroll_remote_router( + ORIGIN, + Into::::into(setup.local_token.contract_address).into() + ); + setup } #[test] diff --git a/cairo/src/tests/token/vault_extensions/hyp_erc20_collateral_vault_deposit_test.cairo b/cairo/src/tests/token/vault_extensions/hyp_erc20_collateral_vault_deposit_test.cairo new file mode 100644 index 0000000..b954cfb --- /dev/null +++ b/cairo/src/tests/token/vault_extensions/hyp_erc20_collateral_vault_deposit_test.cairo @@ -0,0 +1,260 @@ +use hyperlane_starknet::contracts::mocks::mock_mailbox::{ + IMockMailboxDispatcher, IMockMailboxDispatcherTrait +}; +use hyperlane_starknet::contracts::mocks::{ + test_erc20::{ITestERC20Dispatcher, ITestERC20DispatcherTrait}, +}; +use hyperlane_starknet::contracts::token::extensions::hyp_erc20_collateral_vault_deposit::{ + IHypERC20CollateralVaultDepositDispatcher, IHypERC20CollateralVaultDepositDispatcherTrait +}; +use hyperlane_starknet::contracts::token::interfaces::ierc4626::{ + IERC4626Dispatcher, IERC4626DispatcherTrait +}; +use openzeppelin::access::ownable::interface::{IOwnableDispatcher, IOwnableDispatcherTrait}; +use snforge_std::{declare, ContractClassTrait, CheatTarget, start_prank, stop_prank,}; +use starknet::ContractAddress; +use super::super::hyp_erc20::common::{ + Setup, TOTAL_SUPPLY, DECIMALS, ORIGIN, TRANSFER_AMT, ALICE, BOB, E18, REQUIRED_VALUE, + DESTINATION, IHypERC20TestDispatcher, IHypERC20TestDispatcherTrait, setup, + perform_remote_transfer_and_gas, enroll_remote_router, enroll_local_router, + perform_remote_transfer, handle_local_transfer, mint_and_approve +}; + +const DUST_AMOUNT: u256 = 100_000_000_000; // E11 + +fn _transfer_roundtrip_and_increase_yields( + setup: @Setup, vault: ContractAddress, transfer_amount: u256, yield_amount: u256 +) { + // Transfer from Alice to Bob + start_prank(CheatTarget::One((*setup).primary_token.contract_address), ALICE()); + (*setup).primary_token.approve((*setup).local_token.contract_address, transfer_amount); + stop_prank(CheatTarget::One((*setup).primary_token.contract_address)); + perform_remote_transfer(setup, 0, transfer_amount); + // Increase vault balance, which will reduce share redeemed for the same amount + (*setup).primary_token.mint(vault, yield_amount); + start_prank(CheatTarget::One((*setup).remote_token.contract_address), BOB()); + (*setup) + .remote_token + .transfer_remote( + ORIGIN, + Into::::into(BOB()) + .into(), // orginal test has Bob here as well but not sure, should it be alice + transfer_amount, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One((*setup).remote_token.contract_address)); +} + +fn assert_approx_eq_abs(lhs: u256, rhs: u256, relaxation: u256) { + let diff = if lhs >= rhs { + lhs - rhs + } else { + rhs - lhs + }; + assert!(diff <= relaxation, "Values are not approximately equal"); +} + +fn setup_vault() -> (Setup, IERC4626Dispatcher, IHypERC20CollateralVaultDepositDispatcher) { + let mut setup = setup(); + let contract = declare("ERC4626Mock").unwrap(); + let mut calldata: Array = array![]; + setup.primary_token.contract_address.serialize(ref calldata); + let name: ByteArray = "Regular Vault"; + let symbol: ByteArray = "RV"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + let (vault, _) = contract.deploy(@calldata).unwrap(); + println!("VAULT: {:?}", vault); + + let contract = declare("HypERC20CollateralVaultDeposit").unwrap(); + let mut calldata: Array = array![]; + setup.local_mailbox.contract_address.serialize(ref calldata); + vault.serialize(ref calldata); + starknet::get_contract_address().serialize(ref calldata); + setup.noop_hook.contract_address.serialize(ref calldata); + setup.implementation.interchain_security_module().serialize(ref calldata); + let (implementation, _) = contract.deploy(@calldata).unwrap(); + println!("HypERC20CollateralVaultDeposit: {:?}", implementation); + setup.local_token = IHypERC20TestDispatcher { contract_address: implementation }; + setup + .local_token + .enroll_remote_router( + DESTINATION, + Into::::into(setup.remote_token.contract_address).into() + ); + + setup.remote_mailbox.set_default_hook(setup.noop_hook.contract_address); + setup.remote_mailbox.set_required_hook(setup.noop_hook.contract_address); + + setup.primary_token.transfer(ALICE(), 1000 * E18); + + setup + .remote_token + .enroll_remote_router( + ORIGIN, + Into::::into(setup.local_token.contract_address).into() + ); + ( + setup, + IERC4626Dispatcher { contract_address: vault }, + IHypERC20CollateralVaultDepositDispatcher { contract_address: implementation } + ) +} + +fn erc4626_vault_deposit_remote_transfer_deposits_into_vault( + mut transfer_amount: u256 +) -> (Setup, IERC4626Dispatcher, IHypERC20CollateralVaultDepositDispatcher) { + transfer_amount %= TOTAL_SUPPLY + 1; + let (mut setup, mut vault, mut erc20_collateral_vault_deposit) = setup_vault(); + start_prank(CheatTarget::One(setup.primary_token.contract_address), ALICE()); + mint_and_approve(@setup, transfer_amount, ALICE(), setup.local_token.contract_address); + stop_prank(CheatTarget::One(setup.primary_token.contract_address)); + // Check vault shares balance before and after transfer + assert_eq!(vault.max_redeem(erc20_collateral_vault_deposit.contract_address), 0); + assert_eq!(erc20_collateral_vault_deposit.get_asset_deposited(), 0); + + start_prank(CheatTarget::One(setup.primary_token.contract_address), ALICE()); + setup.primary_token.approve(setup.local_token.contract_address, transfer_amount); + stop_prank(CheatTarget::One(setup.primary_token.contract_address)); + perform_remote_transfer(@setup, 0, transfer_amount); + assert_approx_eq_abs( + vault.max_redeem(erc20_collateral_vault_deposit.contract_address), transfer_amount, 1 + ); + assert_eq!(erc20_collateral_vault_deposit.get_asset_deposited(), transfer_amount); + (setup, vault, erc20_collateral_vault_deposit) +} + +#[test] +fn test_fuzz_erc4626_vault_deposit_remote_transfer_deposits_into_vault(mut transfer_amount: u256) { + erc4626_vault_deposit_remote_transfer_deposits_into_vault(transfer_amount); +} + +#[test] +fn test_fuzz_erc4626_vault_deposit_remote_transfer_withdraws_from_vault(mut transfer_amount: u256) { + transfer_amount %= TOTAL_SUPPLY + 1; + let (mut setup, mut vault, mut erc20_collateral_vault_deposit) = setup_vault(); + start_prank(CheatTarget::One(setup.primary_token.contract_address), ALICE()); + mint_and_approve(@setup, transfer_amount, ALICE(), setup.local_token.contract_address); + stop_prank(CheatTarget::One(setup.primary_token.contract_address)); + _transfer_roundtrip_and_increase_yields( + @setup, vault.contract_address, transfer_amount, DUST_AMOUNT + ); + // Check Alice's local token balance + let prev_balance = setup.local_token.balance_of(ALICE()); + handle_local_transfer(@setup, transfer_amount); + let after_balance = setup.local_token.balance_of(ALICE()); + assert_eq!(after_balance, prev_balance + transfer_amount); + assert_eq!(erc20_collateral_vault_deposit.get_asset_deposited(), 0); +} + +#[test] +fn test_fuzz_erc4626_vault_deposit_remote_transfer_withdraw_less_shares(mut reward_amount: u256) { + reward_amount %= TOTAL_SUPPLY + 1; + if reward_amount < DUST_AMOUNT { + reward_amount += DUST_AMOUNT; + } + let (mut setup, mut vault, mut erc20_collateral_vault_deposit) = setup_vault(); + _transfer_roundtrip_and_increase_yields( + @setup, vault.contract_address, TRANSFER_AMT, reward_amount + ); + // Check Alice's local token balance + let prev_balance = setup.local_token.balance_of(ALICE()); + handle_local_transfer(@setup, TRANSFER_AMT); + let after_balance = setup.local_token.balance_of(ALICE()); + assert_eq!(after_balance, prev_balance + TRANSFER_AMT); + // Has leftover shares, but no assets deposited] + assert_eq!(erc20_collateral_vault_deposit.get_asset_deposited(), 0); + assert_gt!(vault.max_redeem(erc20_collateral_vault_deposit.contract_address), 0); +} + +#[test] +#[should_panic] +fn test_fuzz_erc4626_vault_deposit_remote_transfer_sweep_revert_non_owner(mut reward_amount: u256) { + reward_amount %= TOTAL_SUPPLY + 1; + if reward_amount < DUST_AMOUNT { + reward_amount += DUST_AMOUNT; + } + let (mut setup, mut vault, mut erc20_collateral_vault_deposit) = setup_vault(); + _transfer_roundtrip_and_increase_yields( + @setup, vault.contract_address, TRANSFER_AMT, reward_amount + ); + start_prank(CheatTarget::One(erc20_collateral_vault_deposit.contract_address), BOB()); + erc20_collateral_vault_deposit.sweep(); + stop_prank(CheatTarget::One(erc20_collateral_vault_deposit.contract_address)); +} + +#[test] +fn test_fuzz_erc4626_vault_deposit_remote_transfer_sweep_no_excess_shares( + mut transfer_amount: u256 +) { + let (mut setup, _, mut erc20_collateral_vault_deposit) = + erc4626_vault_deposit_remote_transfer_deposits_into_vault( + transfer_amount + ); + let owner = IOwnableDispatcher { + contract_address: erc20_collateral_vault_deposit.contract_address + } + .owner(); + let owner_balance_prev = setup.primary_token.balance_of(owner); + erc20_collateral_vault_deposit.sweep(); + let owner_balance_after = setup.primary_token.balance_of(owner); + assert_eq!(owner_balance_prev, owner_balance_after); +} + +#[test] +fn test_erc4626_vault_deposit_remote_transfer_sweep_excess_shares_12312(mut reward_amount: u256) { + reward_amount %= TOTAL_SUPPLY + 1; + if reward_amount < DUST_AMOUNT { + reward_amount += DUST_AMOUNT; + } + let (mut setup, mut vault, mut erc20_collateral_vault_deposit) = setup_vault(); + _transfer_roundtrip_and_increase_yields( + @setup, vault.contract_address, TRANSFER_AMT, reward_amount + ); + handle_local_transfer(@setup, TRANSFER_AMT); + let owner = IOwnableDispatcher { + contract_address: erc20_collateral_vault_deposit.contract_address + } + .owner(); + let owner_balance_prev = setup.primary_token.balance_of(owner); + let excess_amount = vault.max_redeem(erc20_collateral_vault_deposit.contract_address); + erc20_collateral_vault_deposit.sweep(); + let owner_balance_after = setup.primary_token.balance_of(owner); + assert_gt!(owner_balance_after, owner_balance_prev + excess_amount); +} + +#[test] +fn test_erc4626_vault_deposit_remote_transfer_sweep_excess_shares_multiple_deposit( + mut reward_amount: u256 +) { + reward_amount %= TOTAL_SUPPLY + 1; + if reward_amount < DUST_AMOUNT { + reward_amount += DUST_AMOUNT; + } + let (mut setup, mut vault, mut erc20_collateral_vault_deposit) = setup_vault(); + _transfer_roundtrip_and_increase_yields( + @setup, vault.contract_address, TRANSFER_AMT, reward_amount + ); + handle_local_transfer(@setup, TRANSFER_AMT); + + let owner = IOwnableDispatcher { + contract_address: erc20_collateral_vault_deposit.contract_address + } + .owner(); + let owner_balance_prev = setup.primary_token.balance_of(owner); + let excess_amount = vault.max_redeem(erc20_collateral_vault_deposit.contract_address); + // Deposit again for Alice + start_prank(CheatTarget::One(setup.primary_token.contract_address), ALICE()); + setup.primary_token.approve(setup.local_token.contract_address, TRANSFER_AMT); + stop_prank(CheatTarget::One(setup.primary_token.contract_address)); + perform_remote_transfer(@setup, 0, TRANSFER_AMT); + // Sweep and check + erc20_collateral_vault_deposit.sweep(); + let owner_balance_after = setup.primary_token.balance_of(owner); + assert_gt!(owner_balance_after, owner_balance_prev + excess_amount); +} + +// NOTE: Not applicable on Starknet +fn test_benchmark_overhead_gas_usage() {}