From f0b05d4ea8df6a444b1672cec0388a86f6ac8201 Mon Sep 17 00:00:00 2001 From: Ege Caner <67071243+EgeCaner@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:33:10 +0300 Subject: [PATCH] hyp_erc20_vault_tests (#114) * hyp_erc20_vaul_tests * remove unused import * remove unused import --- .../crates/mocks/src/erc4626_component.cairo | 101 ++- .../src/erc4626_yield_sharing_mock.cairo | 313 +++++++-- cairo/crates/mocks/src/mock_mailbox.cairo | 11 +- cairo/crates/mocks/src/test_ism.cairo | 6 +- .../token/src/components/token_message.cairo | 5 +- .../src/extensions/hyp_erc20_vault.cairo | 2 +- .../hyp_erc20_vault_collateral.cairo | 42 +- .../crates/token/tests/hyp_erc20/common.cairo | 31 +- .../token/tests/hyp_erc721/common.cairo | 3 + cairo/crates/token/tests/lib.cairo | 2 +- .../hyp_erc20_vault_test.cairo | 610 ++++++++++++++++++ 11 files changed, 1025 insertions(+), 101 deletions(-) create mode 100644 cairo/crates/token/tests/vault_extensions/hyp_erc20_vault_test.cairo diff --git a/cairo/crates/mocks/src/erc4626_component.cairo b/cairo/crates/mocks/src/erc4626_component.cairo index 6b5ec6d..26c9391 100644 --- a/cairo/crates/mocks/src/erc4626_component.cairo +++ b/cairo/crates/mocks/src/erc4626_component.cairo @@ -1,4 +1,5 @@ //! Modified from {https://github.com/0xHashstack/hashstack_contracts/blob/main/src/token/erc4626/erc4626component.cairo} +//! Modified from {https://github.com/nodeset-org/erc4626-cairo/blob/main/src/erc4626/erc4626.cairo} use starknet::ContractAddress; #[starknet::component] @@ -16,7 +17,6 @@ pub mod ERC4626Component { ERC20Component, ERC20HooksEmptyImpl, ERC20Component::Errors as ERC20Errors }; use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use token::interfaces::ierc4626::{IERC4626, IERC4626Camel, IERC4626Metadata}; #[storage] @@ -55,11 +55,11 @@ pub mod ERC4626Component { 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 mod Errors { + pub const EXCEEDED_MAX_DEPOSIT: felt252 = 'ERC4626: exceeded max deposit'; + pub const EXCEEDED_MAX_MINT: felt252 = 'ERC4626: exceeded max mint'; + pub const EXCEEDED_MAX_REDEEM: felt252 = 'ERC4626: exceeded max redeem'; + pub const EXCEEDED_MAX_WITHDRAW: felt252 = 'ERC4626: exceeded max withdraw'; } pub trait ERC4626HooksTrait { @@ -166,16 +166,19 @@ pub mod ERC4626Component { } fn convert_to_assets(self: @ComponentState, shares: u256) -> u256 { - self._convert_to_assets(shares) + self._convert_to_assets(shares, false) } fn convert_to_shares(self: @ComponentState, assets: u256) -> u256 { - self._convert_to_shares(assets) + self._convert_to_shares(assets, false) } fn deposit( ref self: ComponentState, assets: u256, receiver: ContractAddress ) -> u256 { + let max_assets = self.max_deposit(receiver); + assert(max_assets >= assets, Errors::EXCEEDED_MAX_DEPOSIT); + let caller = get_caller_address(); let shares = self.preview_deposit(assets); self._deposit(caller, receiver, assets, shares); @@ -185,6 +188,9 @@ pub mod ERC4626Component { fn mint( ref self: ComponentState, shares: u256, receiver: ContractAddress ) -> u256 { + let max_shares = self.max_mint(receiver); + assert(max_shares >= shares, Errors::EXCEEDED_MAX_MINT); + let caller = get_caller_address(); let assets = self.preview_mint(shares); self._deposit(caller, receiver, assets, shares); @@ -192,19 +198,19 @@ pub mod ERC4626Component { } fn preview_deposit(self: @ComponentState, assets: u256) -> u256 { - self._convert_to_shares(assets) + self._convert_to_shares(assets, false) } fn preview_mint(self: @ComponentState, shares: u256) -> u256 { - self._convert_to_assets(shares) + self._convert_to_assets(shares, true) } fn preview_redeem(self: @ComponentState, shares: u256) -> u256 { - self._convert_to_assets(shares) + self._convert_to_assets(shares, false) } fn preview_withdraw(self: @ComponentState, assets: u256) -> u256 { - self._convert_to_shares(assets) + self._convert_to_shares(assets, true) } fn max_deposit(self: @ComponentState, receiver: ContractAddress) -> u256 { @@ -223,7 +229,7 @@ pub mod ERC4626Component { 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) + self._convert_to_assets(balance, false) } fn redeem( @@ -232,6 +238,9 @@ pub mod ERC4626Component { receiver: ContractAddress, owner: ContractAddress ) -> u256 { + let max_shares = self.max_redeem(owner); + assert(shares <= max_shares, Errors::EXCEEDED_MAX_REDEEM); + let caller = get_caller_address(); let assets = self.preview_redeem(shares); self._withdraw(caller, receiver, owner, assets, shares); @@ -249,6 +258,9 @@ pub mod ERC4626Component { receiver: ContractAddress, owner: ContractAddress ) -> u256 { + let max_assets = self.max_withdraw(owner); + assert(assets <= max_assets, Errors::EXCEEDED_MAX_WITHDRAW); + let caller = get_caller_address(); let shares = self.preview_withdraw(assets); self._withdraw(caller, receiver, owner, assets, shares); @@ -303,27 +315,27 @@ pub mod ERC4626Component { } fn convertToAssets(self: @ComponentState, shares: u256) -> u256 { - self._convert_to_assets(shares) + self._convert_to_assets(shares, false) } fn convertToShares(self: @ComponentState, assets: u256) -> u256 { - self._convert_to_shares(assets) + self._convert_to_shares(assets, false) } fn previewDeposit(self: @ComponentState, assets: u256) -> u256 { - self._convert_to_shares(assets) + self._convert_to_shares(assets, false) } fn previewMint(self: @ComponentState, shares: u256) -> u256 { - self._convert_to_assets(shares) + self._convert_to_assets(shares, true) } fn previewRedeem(self: @ComponentState, shares: u256) -> u256 { - self._convert_to_assets(shares) + self._convert_to_assets(shares, false) } fn previewWithdraw(self: @ComponentState, assets: u256) -> u256 { - self._convert_to_shares(assets) + self._convert_to_shares(assets, true) } fn totalAssets(self: @ComponentState) -> u256 { @@ -348,6 +360,27 @@ pub mod ERC4626Component { } } + fn pow_256(self: u256, mut exponent: u8) -> u256 { + if self == 0 { + return 0; + } + let mut result = 1; + let mut base = self; + + loop { + if exponent & 1 == 1 { + result = result * base; + } + + exponent = exponent / 2; + if exponent == 0 { + break result; + } + + base = base * base; + } + } + #[generate_trait] pub impl InternalImpl< TContractState, @@ -373,21 +406,29 @@ pub mod ERC4626Component { 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 + fn _convert_to_assets( + self: @ComponentState, shares: u256, round: bool + ) -> u256 { + let total_assets = self.total_assets() + 1; + let total_shares = self.total_supply() + pow_256(10, self.ERC4626_offset.read()); + let assets = shares * total_assets / total_shares; + if round && ((assets * total_shares) / total_assets < shares) { + assets + 1 } else { - (shares * self.total_assets()) / supply + assets } } - fn _convert_to_shares(self: @ComponentState, assets: u256) -> u256 { - let supply: u256 = self.total_supply(); - if (assets == 0 || supply == 0) { - assets + fn _convert_to_shares( + self: @ComponentState, assets: u256, round: bool + ) -> u256 { + let total_assets = self.total_assets() + 1; + let total_shares = self.total_supply() + pow_256(10, self.ERC4626_offset.read()); + let share = assets * total_shares / total_assets; + if round && ((share * total_assets) / total_shares < assets) { + share + 1 } else { - (assets * supply) / self.total_assets() + share } } @@ -436,7 +477,7 @@ pub mod ERC4626Component { self.emit(Withdraw { sender: caller, receiver, owner, assets, shares }); - Hooks::before_withdraw(ref self, caller, receiver, owner, assets, shares); + Hooks::after_withdraw(ref self, caller, receiver, owner, assets, shares); } fn _decimals_offset(self: @ComponentState) -> u8 { diff --git a/cairo/crates/mocks/src/erc4626_yield_sharing_mock.cairo b/cairo/crates/mocks/src/erc4626_yield_sharing_mock.cairo index 9c7da73..7212a66 100644 --- a/cairo/crates/mocks/src/erc4626_yield_sharing_mock.cairo +++ b/cairo/crates/mocks/src/erc4626_yield_sharing_mock.cairo @@ -1,42 +1,51 @@ +//! Modified from {https://github.com/nodeset-org/erc4626-cairo/blob/main/src/erc4626/erc4626.cairo} #[starknet::interface] -trait IERC4626YieldSharing { +pub trait IERC4626YieldSharing { fn set_fee(ref self: TContractState, new_fee: u256); fn get_claimable_fees(self: @TContractState) -> u256; + fn scale(self: @TContractState) -> u256; + fn accumulated_fees(self: @TContractState) -> u256; + fn last_vault_balance(self: @TContractState) -> u256; } #[starknet::contract] mod ERC4626YieldSharingMock { use contracts::libs::math; use core::integer::BoundedInt; - use mocks::erc4626_component::{ERC4626Component, ERC4626HooksEmptyImpl}; use openzeppelin::access::ownable::{OwnableComponent}; use openzeppelin::introspection::src5::SRC5Component; - use openzeppelin::token::erc20::ERC20Component; - use openzeppelin::token::erc20::interface::{IERC20, IERC20Dispatcher, IERC20DispatcherTrait}; + use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; use starknet::{get_contract_address, get_caller_address, ContractAddress}; - use token::interfaces::ierc4626::IERC4626; + use token::interfaces::ierc4626::{IERC4626, IERC4626Camel}; - 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; - + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl; + impl ERC20InternalImpl = ERC20Component::InternalImpl; #[abi(embed_v0)] impl OwnableImpl = OwnableComponent::OwnableImpl; impl OwnableInternalImpl = OwnableComponent::InternalImpl; // E18 const SCALE: u256 = 1_000_000_000_000_000_000; + pub mod Errors { + pub const EXCEEDED_MAX_DEPOSIT: felt252 = 'ERC4626: exceeded max deposit'; + pub const EXCEEDED_MAX_MINT: felt252 = 'ERC4626: exceeded max mint'; + pub const EXCEEDED_MAX_REDEEM: felt252 = 'ERC4626: exceeded max redeem'; + pub const EXCEEDED_MAX_WITHDRAW: felt252 = 'ERC4626: exceeded max withdraw'; + } + #[storage] struct Storage { fee: u256, accumulated_fees: u256, last_vault_balance: u256, - #[substorage(v0)] - erc4626: ERC4626Component::Storage, + ERC4626_asset: ContractAddress, + ERC4626_underlying_decimals: u8, + ERC4626_offset: u8, #[substorage(v0)] erc20: ERC20Component::Storage, #[substorage(v0)] @@ -48,8 +57,8 @@ mod ERC4626YieldSharingMock { #[event] #[derive(Drop, starknet::Event)] enum Event { - #[flat] - ERC4626Event: ERC4626Component::Event, + Deposit: Deposit, + Withdraw: Withdraw, #[flat] ERC20Event: ERC20Component::Event, #[flat] @@ -58,6 +67,28 @@ mod ERC4626YieldSharingMock { OwnableEvent: OwnableComponent::Event } + #[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 + } + #[constructor] fn constructor( ref self: ContractState, @@ -66,11 +97,17 @@ mod ERC4626YieldSharingMock { symbol: ByteArray, initial_fee: u256 ) { - self.erc4626.initializer(asset, name, symbol, 0); + let dispatcher = ERC20ABIDispatcher { contract_address: asset }; + self.ERC4626_offset.write(0); + let decimals = dispatcher.decimals(); + self.erc20.initializer(name, symbol); + self.ERC4626_asset.write(asset); + self.ERC4626_underlying_decimals.write(decimals); self.fee.write(initial_fee); self.ownable.initializer(get_caller_address()); } + #[abi(embed_v0)] pub impl ERC4626YieldSharingImpl of super::IERC4626YieldSharing { fn set_fee(ref self: ContractState, new_fee: u256) { self.ownable.assert_only_owner(); @@ -78,7 +115,9 @@ mod ERC4626YieldSharingMock { } fn get_claimable_fees(self: @ContractState) -> u256 { - let new_vault_balance = IERC20Dispatcher { contract_address: self.erc4626.asset() } + let new_vault_balance = ERC20ABIDispatcher { + contract_address: self.ERC4626_asset.read() + } .balance_of(get_contract_address()); let last_vault_balance = self.last_vault_balance.read(); if new_vault_balance <= last_vault_balance { @@ -90,37 +129,49 @@ mod ERC4626YieldSharingMock { self.accumulated_fees.read() + new_fees } + + fn scale(self: @ContractState) -> u256 { + SCALE + } + fn accumulated_fees(self: @ContractState) -> u256 { + self.accumulated_fees.read() + } + + fn last_vault_balance(self: @ContractState) -> u256 { + self.last_vault_balance.read() + } } - pub impl ERC4626 of IERC4626 { + #[abi(embed_v0)] + pub impl ERC4626Impl of IERC4626 { fn name(self: @ContractState) -> ByteArray { - self.erc4626.name() + self.erc20.name() } fn symbol(self: @ContractState) -> ByteArray { - self.erc4626.symbol() + self.erc20.symbol() } fn decimals(self: @ContractState) -> u8 { - self.erc4626.decimals() + self.ERC4626_underlying_decimals.read() + self.ERC4626_offset.read() } fn total_supply(self: @ContractState) -> u256 { - self.erc4626.total_supply() + self.erc20.total_supply() } fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { - self.erc4626.balance_of(account) + self.erc20.balance_of(account) } fn allowance( self: @ContractState, owner: ContractAddress, spender: ContractAddress ) -> u256 { - self.erc4626.allowance(owner, spender) + self.erc20.allowance(owner, spender) } fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { - self.erc4626.transfer(recipient, amount) + self.erc20.transfer(recipient, amount) } fn transfer_from( @@ -129,49 +180,63 @@ mod ERC4626YieldSharingMock { recipient: ContractAddress, amount: u256 ) -> bool { - self.erc4626.transfer_from(sender, recipient, amount) + self.erc20.transfer_from(sender, recipient, amount) } fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { - self.erc4626.approve(spender, amount) + self.erc20.approve(spender, amount) } fn asset(self: @ContractState) -> ContractAddress { - self.erc4626.asset() + self.ERC4626_asset.read() } fn convert_to_assets(self: @ContractState, shares: u256) -> u256 { - self.erc4626.convert_to_assets(shares) + self._convert_to_assets(shares, false) } fn convert_to_shares(self: @ContractState, assets: u256) -> u256 { - self.erc4626.convert_to_shares(assets) + self._convert_to_shares(assets, false) } // 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) + let max_assets = self.max_deposit(receiver); + assert(max_assets >= assets, Errors::EXCEEDED_MAX_DEPOSIT); + + let caller = get_caller_address(); + let shares = self.preview_deposit(assets); + self._deposit(caller, receiver, assets, shares); + + shares } fn mint(ref self: ContractState, shares: u256, receiver: ContractAddress) -> u256 { - self.erc4626.mint(shares, receiver) + let max_shares = self.max_mint(receiver); + assert(max_shares >= shares, Errors::EXCEEDED_MAX_MINT); + + let caller = get_caller_address(); + let assets = self.preview_mint(shares); + self._deposit(caller, receiver, assets, shares); + + assets } fn preview_deposit(self: @ContractState, assets: u256) -> u256 { - self.erc4626.preview_deposit(assets) + self._convert_to_shares(assets, false) } fn preview_mint(self: @ContractState, shares: u256) -> u256 { - self.erc4626.preview_mint(shares) + self._convert_to_assets(shares, true) } fn preview_redeem(self: @ContractState, shares: u256) -> u256 { - self.erc4626.preview_redeem(shares) + self._convert_to_assets(shares, false) } fn preview_withdraw(self: @ContractState, assets: u256) -> u256 { - self.erc4626.preview_withdraw(assets) + self._convert_to_shares(assets, true) } fn max_deposit(self: @ContractState, receiver: ContractAddress) -> u256 { @@ -183,35 +248,136 @@ mod ERC4626YieldSharingMock { } fn max_redeem(self: @ContractState, owner: ContractAddress) -> u256 { - self.erc4626.max_redeem(owner) + self.erc20.balance_of(owner) } fn max_withdraw(self: @ContractState, owner: ContractAddress) -> u256 { - self.erc4626.max_withdraw(owner) + let balance = self.erc20.balance_of(owner); + let shares = self._convert_to_assets(balance, false); + shares } // Overriden fn redeem( ref self: ContractState, shares: u256, receiver: ContractAddress, owner: ContractAddress ) -> u256 { self._accrue_yield(); - self.erc4626.redeem(shares, receiver, owner) + let max_shares = self.max_redeem(owner); + assert(shares <= max_shares, Errors::EXCEEDED_MAX_REDEEM); + + let caller = get_caller_address(); + let assets = self.preview_redeem(shares); + self._withdraw(caller, receiver, owner, assets, shares); + assets } - // Overriden + fn total_assets(self: @ContractState) -> u256 { - self.erc4626.total_assets() - self.get_claimable_fees() + let dispatcher = ERC20ABIDispatcher { contract_address: self.ERC4626_asset.read() }; + dispatcher.balance_of(get_contract_address()) - self.get_claimable_fees() } fn withdraw( ref self: ContractState, assets: u256, receiver: ContractAddress, owner: ContractAddress ) -> u256 { - self.erc4626.withdraw(assets, receiver, owner) + let max_assets = self.max_withdraw(owner); + assert(assets <= max_assets, Errors::EXCEEDED_MAX_WITHDRAW); + + let caller = get_caller_address(); + let shares = self.preview_withdraw(assets); + self._withdraw(caller, receiver, owner, assets, shares); + + shares + } + } + + #[abi(embed_v0)] + pub impl ERC4626CamelImpl of IERC4626Camel { + fn totalSupply(self: @ContractState) -> u256 { + ERC4626Impl::total_supply(self) + } + fn balanceOf(self: @ContractState, account: ContractAddress) -> u256 { + ERC4626Impl::balance_of(self, account) + } + fn transferFrom( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256 + ) -> bool { + ERC4626Impl::transfer_from(ref self, sender, recipient, amount) + } + + fn convertToAssets(self: @ContractState, shares: u256) -> u256 { + self._convert_to_assets(shares, false) + } + + fn convertToShares(self: @ContractState, assets: u256) -> u256 { + self._convert_to_shares(assets, false) + } + + fn previewDeposit(self: @ContractState, assets: u256) -> u256 { + self._convert_to_shares(assets, false) + } + + fn previewMint(self: @ContractState, shares: u256) -> u256 { + self._convert_to_assets(shares, true) + } + + fn previewRedeem(self: @ContractState, shares: u256) -> u256 { + self._convert_to_assets(shares, false) + } + + fn previewWithdraw(self: @ContractState, assets: u256) -> u256 { + self._convert_to_shares(assets, true) + } + + fn totalAssets(self: @ContractState) -> u256 { + self.total_assets() + } + + fn maxDeposit(self: @ContractState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn maxMint(self: @ContractState, receiver: ContractAddress) -> u256 { + BoundedInt::max() + } + + fn maxRedeem(self: @ContractState, owner: ContractAddress) -> u256 { + self.max_redeem(owner) + } + + fn maxWithdraw(self: @ContractState, owner: ContractAddress) -> u256 { + self.max_withdraw(owner) + } + } + + fn pow_256(self: u256, mut exponent: u8) -> u256 { + if self == 0 { + return 0; + } + let mut result = 1; + let mut base = self; + + loop { + if exponent & 1 == 1 { + result = result * base; + } + + exponent = exponent / 2; + if exponent == 0 { + break result; + } + + base = base * base; } } #[generate_trait] impl InternalImpl of InternalTrait { fn _accrue_yield(ref self: ContractState) { - let new_vault_balance = IERC20Dispatcher { contract_address: self.erc4626.asset() } + let new_vault_balance = ERC20ABIDispatcher { + contract_address: self.ERC4626_asset.read() + } .balance_of(get_contract_address()); let last_vault_balance = self.last_vault_balance.read(); if new_vault_balance > last_vault_balance { @@ -222,5 +388,70 @@ mod ERC4626YieldSharingMock { self.last_vault_balance.write(new_vault_balance); } } + + fn _convert_to_assets(self: @ContractState, shares: u256, round: bool) -> u256 { + let total_assets = ERC4626Impl::total_assets(self) + 1; + let total_shares = ERC4626Impl::total_supply(self) + + pow_256(10, self.ERC4626_offset.read()); + let assets = shares * total_assets / total_shares; + if round && ((assets * total_shares) / total_assets < shares) { + assets + 1 + } else { + assets + } + } + + fn _convert_to_shares(self: @ContractState, assets: u256, round: bool) -> u256 { + let total_assets = ERC4626Impl::total_assets(self) + 1; + let total_shares = ERC4626Impl::total_supply(self) + + pow_256(10, self.ERC4626_offset.read()); + let share = assets * total_shares / total_assets; + if round && ((share * total_assets) / total_shares < assets) { + share + 1 + } else { + share + } + } + + fn _deposit( + ref self: ContractState, + caller: ContractAddress, + receiver: ContractAddress, + assets: u256, + shares: u256 + ) { + let dispatcher = ERC20ABIDispatcher { contract_address: self.ERC4626_asset.read() }; + dispatcher.transfer_from(caller, get_contract_address(), assets); + self.erc20.mint(receiver, shares); + self.emit(Deposit { sender: caller, owner: receiver, assets, shares }); + } + + fn _withdraw( + ref self: ContractState, + caller: ContractAddress, + receiver: ContractAddress, + owner: ContractAddress, + assets: u256, + shares: u256 + ) { + if (caller != owner) { + let allowance = self.erc20.allowance(owner, caller); + if (allowance != BoundedInt::max()) { + assert(allowance >= shares, ERC20Component::Errors::APPROVE_FROM_ZERO); + self.erc20.ERC20_allowances.write((owner, caller), allowance - shares); + } + } + + self.erc20.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 }); + } + + fn _decimals_offset(self: @ContractState) -> u8 { + self.ERC4626_offset.read() + } } } diff --git a/cairo/crates/mocks/src/mock_mailbox.cairo b/cairo/crates/mocks/src/mock_mailbox.cairo index 27a32c6..6682953 100644 --- a/cairo/crates/mocks/src/mock_mailbox.cairo +++ b/cairo/crates/mocks/src/mock_mailbox.cairo @@ -64,7 +64,7 @@ pub mod MockMailbox { ContractAddress, ClassHash, get_caller_address, get_block_number, contract_address_const, get_contract_address }; - use super::IMockMailboxDispatcherTrait; + use super::{IMockMailboxDispatcherTrait, IMockMailboxDispatcher}; component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); @@ -256,7 +256,11 @@ pub mod MockMailbox { }; required_hook.post_dispatch(metadata.clone(), message.clone()); let hook = ITestPostDispatchHookDispatcher { contract_address: hook }; - hook.post_dispatch(metadata, message); + hook.post_dispatch(metadata, message.clone()); + let remote_mailbox = self.remote_mailboxes.read(destination_domain); + assert!(remote_mailbox != contract_address_const::<0>()); + IMockMailboxDispatcher { contract_address: remote_mailbox } + .add_inbound_message(message); id } @@ -266,7 +270,7 @@ pub mod MockMailbox { } fn process_next_inbound_message(ref self: ContractState) { - let message = self.inbound_messages.read(self.inbound_unprocessed_nonce.read()); + let message = self.inbound_messages.read(self.inbound_processed_nonce.read()); IMailboxDispatcher { contract_address: starknet::get_contract_address() } .process(BytesTrait::new_empty(), message); self.inbound_processed_nonce.write(self.inbound_processed_nonce.read() + 1); @@ -590,4 +594,3 @@ pub mod MockMailbox { } } } - diff --git a/cairo/crates/mocks/src/test_ism.cairo b/cairo/crates/mocks/src/test_ism.cairo index 8829169..cdaaebc 100644 --- a/cairo/crates/mocks/src/test_ism.cairo +++ b/cairo/crates/mocks/src/test_ism.cairo @@ -1,9 +1,9 @@ use alexandria_bytes::Bytes; - +use contracts::libs::message::Message; #[starknet::interface] pub trait ITestISM { fn set_verify(ref self: TContractState, verify: bool); - fn verify(ref self: TContractState, calldata: Bytes, _calldata: Bytes) -> bool; + fn verify(self: @TContractState, _metadata: Bytes, _message: Message) -> bool; } #[starknet::contract] @@ -27,7 +27,7 @@ pub mod TestISM { self.verify_result.write(verify); } - fn verify(ref self: ContractState, calldata: Bytes, _calldata: Bytes) -> bool { + fn verify(self: @ContractState, _metadata: Bytes, _message: super::Message) -> bool { self.verify_result.read() } } diff --git a/cairo/crates/token/src/components/token_message.cairo b/cairo/crates/token/src/components/token_message.cairo index ed00cd8..7fc295e 100644 --- a/cairo/crates/token/src/components/token_message.cairo +++ b/cairo/crates/token/src/components/token_message.cairo @@ -18,8 +18,9 @@ pub impl TokenMessage of TokenMessageTrait { /// /// A `Bytes` object containing the formatted token message. fn format(recipient: u256, amount: u256, metadata: Bytes) -> Bytes { - let data: Array = array![recipient.low, recipient.high, amount.low, amount.high]; - let mut bytes = BytesTrait::new(4, data); + let mut bytes = BytesTrait::new_empty(); + bytes.append_u256(recipient); + bytes.append_u256(amount); bytes.concat(@metadata); bytes } diff --git a/cairo/crates/token/src/extensions/hyp_erc20_vault.cairo b/cairo/crates/token/src/extensions/hyp_erc20_vault.cairo index 7bb5cdf..6eedb45 100644 --- a/cairo/crates/token/src/extensions/hyp_erc20_vault.cairo +++ b/cairo/crates/token/src/extensions/hyp_erc20_vault.cairo @@ -1,5 +1,5 @@ #[starknet::interface] -trait IHypErc20Vault { +pub trait IHypErc20Vault { fn assets_to_shares(self: @TContractState, amount: u256) -> u256; fn shares_to_assets(self: @TContractState, shares: u256) -> u256; fn share_balance_of(self: @TContractState, account: starknet::ContractAddress) -> u256; diff --git a/cairo/crates/token/src/extensions/hyp_erc20_vault_collateral.cairo b/cairo/crates/token/src/extensions/hyp_erc20_vault_collateral.cairo index 3633bfa..507e0ec 100644 --- a/cairo/crates/token/src/extensions/hyp_erc20_vault_collateral.cairo +++ b/cairo/crates/token/src/extensions/hyp_erc20_vault_collateral.cairo @@ -1,5 +1,5 @@ #[starknet::interface] -trait IHypErc20VaultCollateral { +pub trait IHypErc20VaultCollateral { fn rebase(ref self: TContractState, destination_domain: u32, value: u256); // getters fn get_vault(self: @TContractState) -> starknet::ContractAddress; @@ -116,11 +116,13 @@ mod HypErc20VaultCollateral { 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); @@ -165,11 +167,31 @@ mod HypErc20VaultCollateral { let mut token_metadata: Bytes = BytesTrait::new_empty(); token_metadata.append_u256(exchange_rate); let token_message = TokenMessageTrait::format(recipient, shares, token_metadata); - let message_id = contract_state - .router - ._Router_dispatch( - destination, value, token_message, hook_metadata.unwrap(), hook.unwrap() - ); + let mut message_id = 0; + + match hook_metadata { + Option::Some(hook_metadata) => { + if !hook.is_some() { + panic!("Transfer remote invalid arguments, missing hook"); + } + + message_id = contract_state + .router + ._Router_dispatch( + destination, value, token_message, hook_metadata, hook.unwrap() + ); + }, + Option::None => { + let hook_metadata = contract_state + .gas_router + ._Gas_router_hook_metadata(destination); + let hook = contract_state.mailbox.get_hook(); + message_id = contract_state + .router + ._Router_dispatch(destination, value, token_message, hook_metadata, hook); + } + } + self .emit( TokenRouterComponent::SentTransferRemote { @@ -232,7 +254,7 @@ mod HypErc20VaultCollateral { ); } } - + #[abi(embed_v0)] impl HypeErc20VaultCollateral of super::IHypErc20VaultCollateral { /// Rebases the vault collateral and sends a message to a remote domain. /// diff --git a/cairo/crates/token/tests/hyp_erc20/common.cairo b/cairo/crates/token/tests/hyp_erc20/common.cairo index 55cbdec..2ffed90 100644 --- a/cairo/crates/token/tests/hyp_erc20/common.cairo +++ b/cairo/crates/token/tests/hyp_erc20/common.cairo @@ -20,8 +20,8 @@ use mocks::{ }; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; use snforge_std::{ - declare, ContractClassTrait, CheatTarget, EventSpy, EventAssertions, spy_events, SpyOn, - start_prank, stop_prank, EventFetcher, event_name_hash + declare, ContractClassTrait, ContractClass, CheatTarget, EventSpy, EventAssertions, spy_events, + SpyOn, start_prank, stop_prank, EventFetcher, event_name_hash }; use starknet::ContractAddress; use token::components::token_router::{ITokenRouterDispatcher, ITokenRouterDispatcherTrait}; @@ -49,10 +49,10 @@ pub fn ALICE() -> ContractAddress { pub fn BOB() -> ContractAddress { starknet::contract_address_const::<0x2>() } -fn CAROL() -> ContractAddress { +pub fn CAROL() -> ContractAddress { starknet::contract_address_const::<0x3>() } -fn DANIEL() -> ContractAddress { +pub fn DANIEL() -> ContractAddress { starknet::contract_address_const::<0x4>() } fn PROXY_ADMIN() -> ContractAddress { @@ -130,6 +130,8 @@ pub struct Setup { pub local_token: IHypERC20TestDispatcher, pub igp: ITestInterchainGasPaymentDispatcher, pub erc20_token: ITestERC20Dispatcher, + pub eth_token: MockEthDispatcher, + pub mock_mailbox_contract: ContractClass } pub fn setup() -> Setup { @@ -144,11 +146,11 @@ pub fn setup() -> Setup { let mut calldata: Array = array![]; starknet::get_contract_address().serialize(ref calldata); let (eth_address, _) = contract.deploy(@calldata).unwrap(); - let eth = MockEthDispatcher { contract_address: eth_address }; - eth.mint(ALICE(), 10 * E18); + let eth_token = MockEthDispatcher { contract_address: eth_address }; + eth_token.mint(ALICE(), 10 * E18); - let contract = declare("MockMailbox").unwrap(); - let (local_mailbox, _) = contract + let mock_mailbox_contract = declare("MockMailbox").unwrap(); + let (local_mailbox, _) = mock_mailbox_contract .deploy( @array![ ORIGIN.into(), @@ -160,7 +162,7 @@ pub fn setup() -> Setup { .unwrap(); let local_mailbox = IMockMailboxDispatcher { contract_address: local_mailbox }; - let (remote_mailbox, _) = contract + let (remote_mailbox, _) = mock_mailbox_contract .deploy( @array![ DESTINATION.into(), @@ -219,6 +221,15 @@ pub fn setup() -> Setup { let (remote_token, _) = hyp_erc20_contract.deploy(@calldata).unwrap(); let remote_token = IHypERC20TestDispatcher { contract_address: remote_token }; + let mut calldata: Array = array![]; + DECIMALS.serialize(ref calldata); + local_mailbox.contract_address.serialize(ref calldata); + TOTAL_SUPPLY.serialize(ref calldata); + NAME().serialize(ref calldata); + SYMBOL().serialize(ref calldata); + noop_hook.contract_address.serialize(ref calldata); + igp.contract_address.serialize(ref calldata); + starknet::get_contract_address().serialize(ref calldata); let (local_token, _) = hyp_erc20_contract.deploy(@calldata).unwrap(); let local_token = IHypERC20TestDispatcher { contract_address: local_token }; @@ -237,6 +248,8 @@ pub fn setup() -> Setup { local_token, igp, erc20_token, + eth_token, + mock_mailbox_contract } } diff --git a/cairo/crates/token/tests/hyp_erc721/common.cairo b/cairo/crates/token/tests/hyp_erc721/common.cairo index b648824..284497b 100644 --- a/cairo/crates/token/tests/hyp_erc721/common.cairo +++ b/cairo/crates/token/tests/hyp_erc721/common.cairo @@ -223,6 +223,9 @@ pub fn setup() -> Setup { let (alice, _) = contract.deploy(@array![PUB_KEY]).unwrap(); let (bob, _) = contract.deploy(@array![PUB_KEY]).unwrap(); + local_mailbox.add_remote_mail_box(DESTINATION, remote_mailbox.contract_address); + remote_mailbox.add_remote_mail_box(ORIGIN, local_mailbox.contract_address); + Setup { local_primary_token, remote_primary_token, diff --git a/cairo/crates/token/tests/lib.cairo b/cairo/crates/token/tests/lib.cairo index 9cfa6de..4fb61d6 100644 --- a/cairo/crates/token/tests/lib.cairo +++ b/cairo/crates/token/tests/lib.cairo @@ -14,7 +14,7 @@ pub mod hyp_erc721 { 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 hyp_erc20_vault_test; } diff --git a/cairo/crates/token/tests/vault_extensions/hyp_erc20_vault_test.cairo b/cairo/crates/token/tests/vault_extensions/hyp_erc20_vault_test.cairo new file mode 100644 index 0000000..b386574 --- /dev/null +++ b/cairo/crates/token/tests/vault_extensions/hyp_erc20_vault_test.cairo @@ -0,0 +1,610 @@ +use contracts::interfaces::{IMailboxClientDispatcher, IMailboxClientDispatcherTrait}; +use mocks::erc4626_yield_sharing_mock::{ + IERC4626YieldSharingDispatcher, IERC4626YieldSharingDispatcherTrait +}; +use mocks::mock_mailbox::{IMockMailboxDispatcher, IMockMailboxDispatcherTrait}; +use mocks::{test_erc20::{ITestERC20Dispatcher, ITestERC20DispatcherTrait},}; +use openzeppelin::access::ownable::interface::{IOwnableDispatcher, IOwnableDispatcherTrait}; +use snforge_std::{declare, CheatTarget, start_prank, stop_prank, ContractClass, ContractClassTrait}; +use starknet::ContractAddress; +use super::super::hyp_erc20::common::{ + Setup, TOTAL_SUPPLY, DECIMALS, ORIGIN, TRANSFER_AMT, ALICE, BOB, DANIEL, CAROL, E18, + REQUIRED_VALUE, DESTINATION, IHypERC20TestDispatcher, IHypERC20TestDispatcherTrait, + perform_remote_transfer_and_gas, enroll_remote_router, enroll_local_router, + perform_remote_transfer, handle_local_transfer, mint_and_approve, connect_routers +}; +use super::super::hyp_erc20::common; +use token::components::token_router::{ITokenRouterDispatcher, ITokenRouterDispatcherTrait}; +use token::extensions::{ + hyp_erc20_vault_collateral::{ + IHypErc20VaultCollateralDispatcher, IHypErc20VaultCollateralDispatcherTrait + }, + hyp_erc20_vault::{IHypErc20VaultDispatcher, IHypErc20VaultDispatcherTrait} +}; +use token::interfaces::ierc4626::{IERC4626Dispatcher, IERC4626DispatcherTrait}; + +const PEER_DESTINATION: u32 = 13; +const YIELD: u256 = 5 * E18; +const YIELD_FEES: u256 = E18 / 10; // E17 +const E14: u256 = 100_000_000_000_000; +const E10: u256 = 10_000_000_000; + +fn setup_vault() -> ( + Setup, + IHypErc20VaultCollateralDispatcher, + IERC4626Dispatcher, + IERC4626Dispatcher, + IERC4626YieldSharingDispatcher, + IMockMailboxDispatcher +) { + let mut setup = common::setup(); + // multi-synthetic setup + let default_ism = setup.implementation.interchain_security_module(); + + let (peer_mailbox, _) = setup + .mock_mailbox_contract + .deploy( + @array![ + PEER_DESTINATION.into(), + default_ism.into(), + setup.noop_hook.contract_address.into(), + setup.eth_token.contract_address.into() + ] + ) + .unwrap(); + + let peer_mailbox_dispatcher = IMockMailboxDispatcher { contract_address: peer_mailbox }; + setup.local_mailbox.add_remote_mail_box(PEER_DESTINATION, peer_mailbox); + setup.remote_mailbox.add_remote_mail_box(PEER_DESTINATION, peer_mailbox); + peer_mailbox_dispatcher.add_remote_mail_box(DESTINATION, setup.remote_mailbox.contract_address); + peer_mailbox_dispatcher.add_remote_mail_box(ORIGIN, setup.local_mailbox.contract_address); + + let contract = declare("ERC4626YieldSharingMock").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); + YIELD_FEES.serialize(ref calldata); + start_prank(CheatTarget::All, DANIEL()); + let (vault, _) = contract.deploy(@calldata).unwrap(); + stop_prank(CheatTarget::All); + + let contract = declare("HypErc20VaultCollateral").unwrap(); + let (local_token, _) = contract + .deploy( + @array![ + setup.local_mailbox.contract_address.into(), + vault.into(), + starknet::get_contract_address().into(), + setup.noop_hook.contract_address.into(), + default_ism.into() + ] + ) + .unwrap(); + + let dummy_name: ByteArray = "Dummy Name"; + let dummy_symbol: ByteArray = "DUM"; + let contract = declare("HypErc20Vault").unwrap(); + let mut calldata: Array = array![]; + setup.primary_token.decimals().serialize(ref calldata); + setup.remote_mailbox.contract_address.serialize(ref calldata); + TOTAL_SUPPLY.serialize(ref calldata); + dummy_name.serialize(ref calldata); + dummy_symbol.serialize(ref calldata); + setup.local_mailbox.get_local_domain().serialize(ref calldata); + setup.primary_token.contract_address.serialize(ref calldata); + starknet::get_contract_address().serialize(ref calldata); + setup.noop_hook.contract_address.serialize(ref calldata); + default_ism.serialize(ref calldata); + + let (remote_token, _) = contract.deploy(@calldata).unwrap(); + + let mut calldata: Array = array![]; + setup.primary_token.decimals().serialize(ref calldata); + peer_mailbox.serialize(ref calldata); + TOTAL_SUPPLY.serialize(ref calldata); + dummy_name.serialize(ref calldata); + dummy_symbol.serialize(ref calldata); + setup.local_mailbox.get_local_domain().serialize(ref calldata); + setup.primary_token.contract_address.serialize(ref calldata); + starknet::get_contract_address().serialize(ref calldata); + setup.noop_hook.contract_address.serialize(ref calldata); + default_ism.serialize(ref calldata); + let (peer_token, _) = contract.deploy(@calldata).unwrap(); + + let local_rebasing_token = IHypErc20VaultCollateralDispatcher { contract_address: local_token }; + let remote_rebasing_token = IERC4626Dispatcher { contract_address: remote_token }; + let peer_rebasing_token = IERC4626Dispatcher { contract_address: peer_token }; + setup.primary_token.transfer(ALICE(), 1000 * E18); + let domains = array![ORIGIN, DESTINATION, PEER_DESTINATION]; + let addresses_u256 = array![ + Into::::into(local_token).into(), + Into::::into(remote_token).into(), + Into::::into(peer_token).into() + ]; + + connect_routers(@setup, domains.span(), addresses_u256.span()); + + ( + setup, + local_rebasing_token, + remote_rebasing_token, + peer_rebasing_token, + IERC4626YieldSharingDispatcher { contract_address: vault }, + peer_mailbox_dispatcher + ) +} + +#[test] +fn test_collateral_domain() { + let (_, local_rebasing_token, remote_rebasing_token, _, _, _) = setup_vault(); + assert_eq!( + IHypErc20VaultDispatcher { contract_address: remote_rebasing_token.contract_address } + .get_collateral_domain(), + IMailboxClientDispatcher { contract_address: local_rebasing_token.contract_address } + .get_local_domain() + ); +} + +#[test] +fn test_remote_transfer_rebase_after() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + local_rebasing_token.rebase(DESTINATION, 0); + + setup.remote_mailbox.process_next_inbound_message(); + + assert_eq!( + remote_rebasing_token.balance_of(BOB()), + TRANSFER_AMT + _discounted_yield(yield_sharing_vault) + ); +} + +#[test] +fn test_rebase_with_transfer() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + + assert_approx_eq_rel( + remote_rebasing_token.balance_of(BOB()), + 2 * TRANSFER_AMT + _discounted_yield(yield_sharing_vault), + E14, + ); +} + +#[test] +fn test_synthetic_transfers_with_rebase() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + start_prank(CheatTarget::All, BOB()); + remote_rebasing_token.transfer(CAROL(), TRANSFER_AMT); + stop_prank(CheatTarget::All); + assert_approx_eq_rel( + remote_rebasing_token.balance_of(BOB()), + TRANSFER_AMT + _discounted_yield(yield_sharing_vault), + E14, + ); + assert_approx_eq_rel(remote_rebasing_token.balance_of(CAROL()), TRANSFER_AMT, E14,); +} + +#[test] +fn test_withdrawal_without_yield() { + let (mut setup, mut local_rebasing_token, remote_rebasing_token, _, _, _) = setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + ORIGIN, + Into::::into(BOB()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + + setup.local_mailbox.process_next_inbound_message(); + assert_eq!(setup.primary_token.balance_of(BOB()), TRANSFER_AMT); +} + +#[test] +fn test_withdrawal_with_yield() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + ORIGIN, + Into::::into(BOB()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + + setup.local_mailbox.process_next_inbound_message(); + // BOB gets the yield even though it didn't rebase + let bob_balance = setup.primary_token.balance_of(BOB()); + let expected_balance = TRANSFER_AMT + _discounted_yield(yield_sharing_vault); + assert_approx_eq_rel(bob_balance, expected_balance, E14); + assert_lt!(bob_balance, expected_balance, "Transfer remote should round down"); + assert_eq!(yield_sharing_vault.accumulated_fees(), YIELD / 10); +} + +#[test] +fn test_withdrawal_after_yield() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + local_rebasing_token.rebase(DESTINATION, 0); + setup.remote_mailbox.process_next_inbound_message(); + + // Use balance here since it might be off by <1bp + let bob_balance_remote = remote_rebasing_token.balance_of(BOB()); + + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + ORIGIN, + Into::::into(BOB()).into(), + bob_balance_remote, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + setup.local_mailbox.process_next_inbound_message(); + let bob_balance_primary = setup.primary_token.balance_of(BOB()); + assert_approx_eq_rel( + bob_balance_primary, TRANSFER_AMT + _discounted_yield(yield_sharing_vault), E14 + ); + assert_eq!(yield_sharing_vault.accumulated_fees(), YIELD / 10); +} + +#[test] +fn test_withdrawal_in_flight() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + setup.primary_token.mint(CAROL(), TRANSFER_AMT); + start_prank(CheatTarget::One(setup.primary_token.contract_address), CAROL()); + setup.primary_token.approve(local_rebasing_token.contract_address, TRANSFER_AMT); + stop_prank(CheatTarget::One(setup.primary_token.contract_address)); + start_prank(CheatTarget::One(local_rebasing_token.contract_address), CAROL()); + ITokenRouterDispatcher { contract_address: local_rebasing_token.contract_address } + .transfer_remote( + DESTINATION, + Into::::into(CAROL()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(local_rebasing_token.contract_address)); + + setup.remote_mailbox.process_next_inbound_message(); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + _accrue_yield(@setup, yield_sharing_vault.contract_address); // earning 2x yield to be split + + local_rebasing_token.rebase(DESTINATION, 0); + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), CAROL()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + ORIGIN, + Into::::into(CAROL()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + + setup.local_mailbox.process_next_inbound_message(); + + let claimable_fees = IERC4626YieldSharingDispatcher { + contract_address: yield_sharing_vault.contract_address + } + .get_claimable_fees(); + let carol_balance_primary = setup.primary_token.balance_of(CAROL()); + assert_approx_eq_rel(carol_balance_primary, TRANSFER_AMT + YIELD - (claimable_fees / 2), E14); + + // until we process the rebase, the yield is not added on the remote + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + setup.remote_mailbox.process_next_inbound_message(); + assert_approx_eq_rel( + remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT + YIELD - (claimable_fees / 2), E14 + ); + + assert_eq!(yield_sharing_vault.accumulated_fees(), YIELD / 5); // 0.1 * 2 * yield +} + +#[test] +fn test_withdrawal_after_drawdown() { + let ( + mut setup, mut local_rebasing_token, remote_rebasing_token, _, mut yield_sharing_vault, _ + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + // decrease collateral in vault by 10% + let drawdown = 5 * E18; + start_prank( + CheatTarget::One(setup.primary_token.contract_address), yield_sharing_vault.contract_address + ); + setup.primary_token.burn(drawdown); + stop_prank(CheatTarget::One(setup.primary_token.contract_address)); + + local_rebasing_token.rebase(DESTINATION, 0); + setup.remote_mailbox.process_next_inbound_message(); + + // Use balance here since it might be off by <1bp + let bob_balance_remote = remote_rebasing_token.balance_of(BOB()); + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + ORIGIN, + Into::::into(BOB()).into(), + bob_balance_remote, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + setup.local_mailbox.process_next_inbound_message(); + assert_approx_eq_rel(setup.primary_token.balance_of(BOB()), TRANSFER_AMT - drawdown, E14); +} + +#[test] +fn test_exchange_rate_set_only_by_collateral() { + let ( + mut setup, + mut local_rebasing_token, + remote_rebasing_token, + peer_rebasing_token, + mut yield_sharing_vault, + mut peer_mailbox + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + local_rebasing_token.rebase(DESTINATION, 0); + setup.remote_mailbox.process_next_inbound_message(); + + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + PEER_DESTINATION, + Into::::into(BOB()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + peer_mailbox.process_next_inbound_message(); + + assert_eq!( + IHypErc20VaultDispatcher { contract_address: remote_rebasing_token.contract_address } + .get_exchange_rate(), + 10_450_000_000 + ); // 5 * 0.9 = 4.5% yield + assert_eq!( + IHypErc20VaultDispatcher { contract_address: peer_rebasing_token.contract_address } + .get_exchange_rate(), + E10 + ); // asserting that transfers by the synthetic variant don't impact the exchang rate + + local_rebasing_token.rebase(PEER_DESTINATION, 0); + peer_mailbox.process_next_inbound_message(); + + assert_eq!( + IHypErc20VaultDispatcher { contract_address: peer_rebasing_token.contract_address } + .get_exchange_rate(), + 10_450_000_000 + ); // asserting that the exchange rate is set finally by the collateral variant +} + +#[test] +fn test_cyclic_transfers() { + // ALICE: local -> remote(BOB) + let ( + mut setup, + mut local_rebasing_token, + remote_rebasing_token, + peer_rebasing_token, + mut yield_sharing_vault, + mut peer_mailbox + ) = + setup_vault(); + _perform_remote_transfer_without_expectation( + @setup, local_rebasing_token.contract_address, 0, TRANSFER_AMT + ); + assert_eq!(remote_rebasing_token.balance_of(BOB()), TRANSFER_AMT); + + _accrue_yield(@setup, yield_sharing_vault.contract_address); + + local_rebasing_token.rebase(DESTINATION, 0); // yield is added + setup.remote_mailbox.process_next_inbound_message(); + // BOB: remote -> peer(BOB) (yield is leftover) + start_prank(CheatTarget::One(remote_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: remote_rebasing_token.contract_address } + .transfer_remote( + PEER_DESTINATION, + Into::::into(BOB()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(remote_rebasing_token.contract_address)); + peer_mailbox.process_next_inbound_message(); + + local_rebasing_token.rebase(PEER_DESTINATION, 0); + peer_mailbox.process_next_inbound_message(); + + // BOB: peer -> local(CAROL) + start_prank(CheatTarget::One(peer_rebasing_token.contract_address), BOB()); + ITokenRouterDispatcher { contract_address: peer_rebasing_token.contract_address } + .transfer_remote( + ORIGIN, + Into::::into(CAROL()).into(), + TRANSFER_AMT, + 0, + Option::None, + Option::None + ); + stop_prank(CheatTarget::One(peer_rebasing_token.contract_address)); + setup.local_mailbox.process_next_inbound_message(); + + assert_approx_eq_rel( + remote_rebasing_token.balance_of(BOB()), _discounted_yield(yield_sharing_vault), E14 + ); + assert_eq!(peer_rebasing_token.balance_of(BOB()), 0); + assert_approx_eq_rel(setup.primary_token.balance_of(CAROL()), TRANSFER_AMT, E14); +} + +// skipped in solidity version as well +//#[test] +//fn test_transfer_with_hook_specified() { +// assert(true, ''); +//} + +// NOTE: Not applicable on Starknet +fn test_benchmark_overhead_gas_usage() {} + +/////////////////////////////////////////////////////// +/// Helper functions +/////////////////////////////////////////////////////// + +/// ALICE: local -> remote(BOB) +fn _perform_remote_transfer_without_expectation( + setup: @Setup, local_token: ContractAddress, msg_value: u256, amount: u256 +) { + start_prank(CheatTarget::One((*setup).primary_token.contract_address), ALICE()); + (*setup).primary_token.approve(local_token, TRANSFER_AMT); + stop_prank(CheatTarget::One((*setup).primary_token.contract_address)); + + start_prank(CheatTarget::One(local_token), ALICE()); + ITokenRouterDispatcher { contract_address: local_token } + .transfer_remote( + DESTINATION, + Into::::into(BOB()).into(), + amount, + msg_value, + Option::None, + Option::None + ); + + stop_prank(CheatTarget::One(local_token)); + + (*setup).remote_mailbox.process_next_inbound_message(); +} + +fn _accrue_yield(setup: @Setup, vault: ContractAddress) { + (*setup).primary_token.mint(vault, YIELD); +} + +fn _discounted_yield(vault: IERC4626YieldSharingDispatcher) -> u256 { + YIELD - vault.get_claimable_fees() +} + +/// see {https://github.com/foundry-rs/foundry/blob/e16a75b615f812db6127ea22e23c3ee65504c1f1/crates/cheatcodes/src/test/assert.rs#L533} +fn assert_approx_eq_rel(lhs: u256, rhs: u256, max_delta: u256) { + if lhs == 0 { + if rhs == 0 { + return; + } else { + panic!("eq_rel_assertion error lhs {}, rhs {}, max_delta {}", lhs, rhs, max_delta); + } + } + + let mut delta = if lhs > rhs { + lhs - rhs + } else { + rhs - lhs + }; + + delta *= E18; + delta /= rhs; + + if delta > max_delta { + panic!( + "eq_rel_assertion error lhs {}, rhs {}, max_delta {}, real_delta {}", + lhs, + rhs, + max_delta, + delta + ); + } +}