diff --git a/CHANGELOG.md b/CHANGELOG.md index d4548074..9ad69f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +# [Unreleased] + +### Added + +- `Erc4626` "Tokenized Vault Standard". #465 + +### Changed + +- ## [Unreleased] ### Added diff --git a/Cargo.lock b/Cargo.lock index d3d33a02..48bd19b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1741,6 +1741,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "erc4626-example" +version = "0.2.0-alpha.2" +dependencies = [ + "alloy", + "alloy-primitives", + "e2e", + "eyre", + "openzeppelin-stylus", + "stylus-sdk", + "tokio", +] + [[package]] name = "erc721-consecutive-example" version = "0.2.0-alpha.2" diff --git a/Cargo.toml b/Cargo.toml index 610d339e..8ae7a6b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "examples/erc1155", "examples/erc1155-metadata-uri", "examples/erc1155-supply", + "examples/erc4626", "examples/merkle-proofs", "examples/ownable", "examples/vesting-wallet", @@ -40,6 +41,7 @@ default-members = [ "examples/erc1155", "examples/erc1155-metadata-uri", "examples/erc1155-supply", + "examples/erc4626", "examples/safe-erc20", "examples/merkle-proofs", "examples/ownable", diff --git a/benches/src/erc4626.rs b/benches/src/erc4626.rs new file mode 100644 index 00000000..eb4d29c2 --- /dev/null +++ b/benches/src/erc4626.rs @@ -0,0 +1,103 @@ +use alloy::{ + network::{AnyNetwork, EthereumWallet}, + primitives::Address, + providers::ProviderBuilder, + sol, + sol_types::SolCall, + uint, +}; +use e2e::{receipt, Account}; + +use crate::{ + report::{ContractReport, FunctionReport}, + CacheOpt, +}; + +sol!( + #[sol(rpc)] + contract Erc4626 { + function asset() public view returns (address); + function totalAssets() public view returns (uint256); + function convertToShares(uint256 assets) public view returns (uint256); + function convertToAssets(uint256 shares) public view returns (uint256); + function maxMint(address receiver) public view returns (uint256); + function maxDeposit(address owner) public view returns (uint256); + function maxWithdraw(address owner) public view returns (uint256); + function maxRedeem(address owner) public view returns (uint256); + function previewDeposit(uint256 assets) public view returns (uint256); + function previewMint(uint256 shares) public view returns (uint256); + function previewRedeem(uint256 shares) public view returns (uint256); + function previewWithdraw(uint256 assets) public view returns (uint256); + function deposit(uint256 assets, address receiver) public returns (uint256); + function mint(uint256 shares, address receiver) public returns (uint256); + function redeem(uint256 shares, address receiver,address owner) public returns (uint256); + function withdraw(uint256 assets, address receiver,address owner) public returns (uint256); + } +); + +pub async fn bench() -> eyre::Result { + let reports = run_with(CacheOpt::None).await?; + let report = reports + .into_iter() + .try_fold(ContractReport::new("Erc4626"), ContractReport::add)?; + + let cached_reports = run_with(CacheOpt::Bid(0)).await?; + let report = cached_reports + .into_iter() + .try_fold(report, ContractReport::add_cached)?; + + Ok(report) +} + +pub async fn run_with( + cache_opt: CacheOpt, +) -> eyre::Result> { + let alice = Account::new().await?; + let alice_addr = alice.address(); + let alice_wallet = ProviderBuilder::new() + .network::() + .with_recommended_fillers() + .wallet(EthereumWallet::from(alice.signer.clone())) + .on_http(alice.url().parse()?); + + let bob = Account::new().await?; + let bob_addr = bob.address(); + + let contract_addr = deploy(&alice, cache_opt).await?; + + let contract = Erc4626::new(contract_addr, &alice_wallet); + + // IMPORTANT: Order matters! + use Erc4626::*; + #[rustfmt::skip] + let receipts = vec![ + (assetCall::SIGNATURE, receipt!(contract.asset())?), + (totalAssetsCall::SIGNATURE, receipt!(contract.totalAssets())?), + (convertToSharesCall::SIGNATURE, receipt!(contract.convertToShares(uint!(100_U256)))?), + (convertToAssetsCall::SIGNATURE, receipt!(contract.convertToAssets(uint!(100_U256)))?), + (maxMintCall::SIGNATURE, receipt!(contract.maxMint(bob_addr))?), + (maxDepositCall::SIGNATURE, receipt!(contract.maxDeposit(alice_addr))?), + (maxWithdrawCall::SIGNATURE, receipt!(contract.maxWithdraw(alice_addr))?), + (maxRedeemCall::SIGNATURE, receipt!(contract.maxRedeem(alice_addr))?), + (previewDepositCall::SIGNATURE, receipt!(contract.previewDeposit(uint!(100_U256)))?), + (previewMintCall::SIGNATURE, receipt!(contract.previewMint(uint!(100_U256)))?), + (previewRedeemCall::SIGNATURE, receipt!(contract.previewRedeem(uint!(100_U256)))?), + (previewWithdrawCall::SIGNATURE, receipt!(contract.previewWithdraw(uint!(100_U256)))?), + (depositCall::SIGNATURE, receipt!(contract.deposit(uint!(100_U256), bob_addr))?), + (mintCall::SIGNATURE, receipt!(contract.mint(uint!(100_U256), bob_addr))?), + (redeemCall::SIGNATURE, receipt!(contract.redeem(uint!(100_U256), bob_addr,alice_addr))?), + (withdrawCall::SIGNATURE, receipt!(contract.withdraw(uint!(100_U256), bob_addr, alice_addr))?), + ]; + + receipts + .into_iter() + .map(FunctionReport::new) + .collect::>>() +} + +async fn deploy( + account: &Account, + cache_opt: CacheOpt, +) -> eyre::Result
{ + crate::deploy(account, "Erc4626", None, cache_opt).await +} diff --git a/benches/src/lib.rs b/benches/src/lib.rs index c8fccc1d..0f8a1912 100644 --- a/benches/src/lib.rs +++ b/benches/src/lib.rs @@ -18,6 +18,7 @@ pub mod erc1155; pub mod erc1155_metadata_uri; pub mod erc1155_supply; pub mod erc20; +pub mod erc4626; pub mod erc721; pub mod merkle_proofs; pub mod ownable; diff --git a/benches/src/main.rs b/benches/src/main.rs index 9d801f50..b5677fd9 100644 --- a/benches/src/main.rs +++ b/benches/src/main.rs @@ -1,5 +1,5 @@ use benches::{ - access_control, erc1155, erc1155_metadata_uri, erc20, erc721, + access_control, erc1155, erc1155_metadata_uri, erc20, erc4626, erc721, merkle_proofs, ownable, poseidon, poseidon_sol, report::BenchmarkReport, }; use futures::FutureExt; @@ -15,6 +15,7 @@ async fn main() -> eyre::Result<()> { ownable::bench().boxed(), erc1155::bench().boxed(), erc1155_metadata_uri::bench().boxed(), + erc4626::bench().boxed(), poseidon::bench().boxed(), poseidon_sol::bench().boxed(), ]; diff --git a/contracts/src/token/erc20/extensions/erc4626.rs b/contracts/src/token/erc20/extensions/erc4626.rs new file mode 100644 index 00000000..fbde9975 --- /dev/null +++ b/contracts/src/token/erc20/extensions/erc4626.rs @@ -0,0 +1,668 @@ +//! ERC-4626 Tokenized Vault Standard Implementation. +//! +//! Extends ERC-20 for vaults, enabling minting and burning of "shares" +//! (represented using the [`ERC-20`] inheritance) in exchange for underlying +//! assets. This contract provides standardized workflows for deposits, minting, +//! redemption, and burning of assets. Note: The shares are minted and burned in +//! relation to the assets via the `deposit`, `mint`, `redeem`, and `burn` +//! methods, affecting only the shares token, not the asset token itself. +//! +//! [ERC]: https://eips.ethereum.org/EIPS/eip-4626 +//! +//! [CAUTION] +//! In empty (or nearly empty) ERC-4626 vaults, deposits are at high risk of +//! being stolen through frontrunning with a "donation" to the vault that +//! inflates the price of a share. This is variously known as a donation or +//! inflation attack and is essentially a problem of slippage. Vault deployers +//! can protect against this attack by making an initial deposit of a +//! non-trivial amount of the asset, such that price manipulation becomes +//! infeasible. Withdrawals may similarly be affected by slippage. Users can +//! protect against this attack as well as unexpected slippage in general by +//! verifying the amount received is as expected, using a wrapper that performs +//! these checks such as https://github.com/fei-protocol/ERC4626#erc4626router-and-base[ERC4626Router] +//! +//! The `_decimalsOffset()` corresponds to an offset in the decimal +//! representation between the underlying asset's decimals and the vault +//! decimals. This offset also determines the rate of virtual shares to virtual +//! assets in the vault, which itself determines the initial exchange rate. +//! While not fully preventing the attack, analysis shows that the default +//! offset (0) makes it non-profitable even if an attacker is able to capture +//! value from multiple user deposits, as a result of the value being captured +//! by the virtual shares (out of the attacker's donation) matching the +//! attacker's expected gains. With a larger offset, the attack becomes orders +//! of magnitude more expensive than it is profitable. More details about the +//! underlying math can be found xref:erc4626.adoc#inflation-attack[here]. +//! +//! The drawback of this approach is that the virtual shares do capture (a very +//! small) part of the value being accrued to the vault. Also, if the vault +//! experiences losses, the users try to exit the vault, the virtual shares and +//! assets will cause the first user to exit to experience reduced losses in +//! detriment to the last users that will experience bigger losses. +//! +//! To learn more, check out our xref:ROOT:erc4626.adoc[ERC-4626 guide].. + +use alloy_primitives::{Address, U256}; +use alloy_sol_macro::sol; +use stylus_sdk::{ + contract, evm, msg, + prelude::storage, + storage::{StorageAddress, StorageU8, TopLevelStorage}, + stylus_proc::{public, SolidityError}, +}; + +use crate::token::erc20::{ + self, + utils::{ + safe_erc20::{self, ISafeErc20}, + SafeErc20, + }, + Erc20, IErc20, +}; + +sol! { + /// Emitted when assets are deposited into the contract. + /// + /// * `sender` - Address of the entity initiating the deposit. + /// * `owner` - Address of the recipient who owns the shares. + /// * `assets` - Amount of assets deposited. + /// * `shares` - Number of shares issued to the owner. + #[allow(missing_docs)] + event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); + + + /// Emitted when assets are withdrawn from the contract. + /// + /// * `sender` - Address of the entity initiating the withdrawal. + /// * `receiver` - Address of the recipient receiving the assets. + /// * `owner` - Address of the entity owning the shares. + /// * `assets` - Amount of assets withdrawn. + /// * `shares` - Number of shares burned. + #[allow(missing_docs)] + event Withdraw( + address indexed sender, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); +} + +sol! { + /// Indicates an error where depostit operation failed because + /// deposited more assets than the max amount for `receiver + #[derive(Debug)] + #[allow(missing_docs)] + error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max); + + /// Indicates an error where a mint operation failed because the supplied + /// `shares` exceeded the maximum allowed for the `receiver`. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max); + + /// Indicates an error where a withdrawal operation failed because the + /// supplied `assets` exceeded the maximum allowed for the `owner`. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max); + + /// Indicates an error where a redemption operation failed because the + /// supplied `shares` exceeded the maximum allowed for the `owner`. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max); +} + +/// An [`Erc4626`] error. +#[derive(SolidityError, Debug)] +pub enum Error { + /// Error type from [`SafeErc20`] contract [`safe_erc20::Error`]. + SafeErc20(safe_erc20::Error), + /// Indicates an error where a deposit operation failed because the + /// supplied `assets` exceeded the maximum allowed for the `receiver`. + ExceededMaxDeposit(ERC4626ExceededMaxDeposit), + /// Indicates an error where a mint operation failed because the supplied + /// `shares` exceeded the maximum allowed for the `receiver`. + ExceededMaxMint(ERC4626ExceededMaxMint), + /// Indicates an error where a withdrawal operation failed because the + /// supplied `assets` exceeded the maximum allowed for the `owner`. + ExceededMaxWithdraw(ERC4626ExceededMaxWithdraw), + /// Indicates an error where a redemption operation failed because the + /// supplied `shares` exceeded the maximum allowed for the `owner`. + ExceededMaxRedeem(ERC4626ExceededMaxRedeem), + /// Error type from [`Erc20`] contract [`erc20::Error`]. + Erc20(erc20::Error), +} + +/// State of an [`Erc4626`] token. +#[storage] +pub struct Erc4626 { + /// Token Address of the vault + pub asset_address: StorageAddress, + + /// Token decimals + pub underlying_decimals: StorageU8, +} + +/// ERC-4626 Tokenized Vault Standard Interface +pub trait IERC4626 { + /// The error type associated to this ERC-4626 trait implementation. + type Error: Into>; + + /// Returns the address of the underlying asset that the vault manages. + fn asset(&self) -> Address; + + /// Returns the total amount of the underlying asset held in the vault. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn total_assets(&self) -> U256 { + /// self.erc4626.total_assets(token, &self.erc20) + /// } + /// ``` + fn total_assets(&self, asset: &Erc20) -> U256; + + /// Converts a given amount of assets into the equivalent number of shares. + /// + /// # Parameters + /// - `assets`: Amount of the underlying asset. + /// + /// # Returns + /// The corresponding amount of shares. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn convert_to_shares(&self,assets: U256) -> U256 { + /// self.erc4626.convert_to_shares(token, &self.erc20) + /// } + /// ``` + fn convert_to_shares(&self, assets: U256, asset: &mut Erc20) -> U256; + + /// Converts a given number of shares into the equivalent amount of assets. + /// + /// # Parameters + /// - `shares`: Number of shares. + /// + /// # Returns + /// The corresponding amount of assets. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn convert_to_assets(&self,shares: U256) -> U256 { + /// self.erc4626.convert_to_assets(token, &self.erc20) + /// } + /// ``` + fn convert_to_assets(&self, shares: U256, asset: &mut Erc20) -> U256; + + /// Calculates the maximum amount of assets that can be deposited for a + /// given receiver. + /// + /// # Parameters + /// - `receiver`: The address of the entity receiving the shares. + /// + /// # Returns + /// The maximum depositable amount. + /// # Examples + /// + /// ```rust,ignore + /// fn convert_to_assets(&self,shares: U256) -> U256 { + /// self.erc4626.convert_to_assets(token, &self.erc20) + /// } + /// ``` + fn max_deposit(&self, receiver: Address) -> U256; + + /// Previews the outcome of depositing a specific amount of assets. + /// + /// # Parameters + /// - `assets`: Amount of the underlying asset to deposit. + /// + /// # Returns + /// The number of shares that would be issued. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn preview_deposit(&self,assets: U256,asset: &mut Erc20) -> U256 { + /// self.erc4626.preview_deposit(assets, &self.erc20) + /// } + /// ``` + fn preview_deposit(&self, assets: U256, asset: &mut Erc20) -> U256; + + /// Deposits a specific amount of assets into the vault, issuing shares to + /// the receiver. + /// + /// # Parameters + /// - `assets`: Amount of the underlying asset to deposit. + /// - `receiver`: The address receiving the shares. + /// + /// # Returns + /// The number of shares issued. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn deposit(&self,assets: U256,receiver: Address, asset: &mut Erc20) -> U256 { + /// self.erc4626.deposit(assets, &self.erc20) + /// } + /// ``` + fn deposit( + &mut self, + assets: U256, + receiver: Address, + asset: &mut Erc20, + ) -> Result; + + /// Calculates the maximum number of shares that can be minted for a given + /// receiver. + /// + /// # Parameters + /// - `receiver`: The address of the entity receiving the shares. + /// + /// # Returns + /// The maximum mintable number of shares. + fn max_mint(&self, receiver: Address) -> U256; + + /// Previews the outcome of minting a specific number of shares. + /// + /// # Parameters + /// - `shares`: Number of shares to mint. + /// + /// # Returns + /// The equivalent amount of assets required. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn preview_mint(&self,shares: U256) -> U256 { + /// self.erc4626.preview_mint(token, &self.erc20) + /// } + /// ``` + fn preview_mint(&self, shares: U256, asset: &mut Erc20) -> U256; + + /// Mints a specific number of shares for a given receiver. + /// + /// # Parameters + /// - `shares`: Number of shares to mint. + /// - `receiver`: The address receiving the shares. + /// + /// # Returns + /// The amount of assets deposited. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn mint(&self,shares: U256, receiver: Address,) -> U256 { + /// self.erc4626.mint(shares,receiver, &self.erc20) + /// } + /// ``` + fn mint( + &mut self, + shares: U256, + receiver: Address, + asset: &mut Erc20, + ) -> Result; + + /// Calculates the maximum amount of assets that can be withdrawn by a given + /// owner. + /// + /// # Parameters + /// - `owner`: The address of the entity owning the shares. + /// + /// # Returns + /// The maximum withdrawable amount. + /// + /// # Examples + /// + /// ```rust,ignore + /// fn max_withdraw(&self,owner: Address) -> U256 { + /// self.erc4626.max_withdraw(owner, &self.erc20) + /// } + /// ``` + fn max_withdraw(&self, owner: Address, asset: &mut Erc20) -> U256; + + /// Previews the outcome of withdrawing a specific amount of assets. + /// + /// # Parameters + /// - `assets`: Amount of the underlying asset to withdraw. + /// + /// # Returns + /// The equivalent number of shares required. + fn preview_withdraw(&self, assets: U256, asset: &mut Erc20) -> U256; + + /// Withdraws a specific amount of assets from the vault, deducting shares + /// from the owner. + /// + /// # Parameters + /// - `assets`: Amount of the underlying asset to withdraw. + /// - `receiver`: The address receiving the withdrawn assets. + /// - `owner`: The address owning the shares to be deducted. + /// + /// # Returns + /// The number of shares burned. + fn withdraw( + &mut self, + assets: U256, + receiver: Address, + owner: Address, + asset: &mut Erc20, + safe_erc20: &mut SafeErc20, + ) -> Result; + + /// Calculates the maximum number of shares that can be redeemed by a given + /// owner. + /// + /// # Parameters + /// - `owner`: The address of the entity owning the shares. + /// + /// # Returns + /// The maximum redeemable number of shares. + fn max_redeem(&self, owner: Address, asset: &mut Erc20) -> U256; + + /// Previews the outcome of redeeming a specific number of shares. + /// + /// # Parameters + /// - `shares`: Number of shares to redeem. + /// + /// # Returns + /// The equivalent amount of assets returned. + fn preview_redeem(&self, shares: U256, asset: &mut Erc20) -> U256; + + /// Redeems a specific number of shares for the underlying assets, + /// transferring them to the receiver. + /// + /// # Parameters + /// - `shares`: Number of shares to redeem. + /// - `receiver`: The address receiving the underlying assets. + /// - `owner`: The address owning the shares to be redeemed. + /// + /// # Returns + /// The amount of assets transferred. + fn redeem( + &mut self, + shares: U256, + receiver: Address, + owner: Address, + asset: &mut Erc20, + safe_erc20: &mut SafeErc20, + ) -> Result; +} + +/// NOTE: Implementation of [`TopLevelStorage`] to be able use `&mut self` when +/// calling other contracts and not `&mut (impl TopLevelStorage + +/// BorrowMut)`. Should be fixed in the future by the Stylus team. +unsafe impl TopLevelStorage for Erc4626 {} + +impl IERC4626 for Erc4626 { + type Error = Error; + + fn asset(&self) -> Address { + contract::address() + } + + fn total_assets(&self, asset: &Erc20) -> U256 { + asset.balance_of(contract::address()) + } + + fn convert_to_shares(&self, assets: U256, asset: &mut Erc20) -> U256 { + self._convert_to_shares(assets, asset) + } + + fn convert_to_assets(&self, shares: U256, asset: &mut Erc20) -> U256 { + self._convert_to_assets(shares, asset) + } + + fn max_deposit(&self, _receiver: Address) -> U256 { + U256::MAX + } + + fn preview_deposit(&self, assets: U256, asset: &mut Erc20) -> U256 { + self._convert_to_shares(assets, asset) + } + + fn deposit( + &mut self, + assets: U256, + receiver: Address, + asset: &mut Erc20, + ) -> Result { + let max_assets = self.max_deposit(receiver); + if assets > max_assets { + return Err(Error::ExceededMaxDeposit(ERC4626ExceededMaxDeposit { + receiver, + assets, + max: max_assets, + })); + } + + let shares = self.preview_deposit(assets, asset); + self._deposit(msg::sender(), receiver, assets, shares, asset)?; + Ok(shares) + } + + fn max_mint(&self, _receiver: Address) -> U256 { + U256::MAX + } + + fn preview_mint(&self, shares: U256, asset: &mut Erc20) -> U256 { + self._convert_to_assets(shares, asset) + } + + fn mint( + &mut self, + shares: U256, + receiver: Address, + asset: &mut Erc20, + ) -> Result { + let max_shares = self.max_mint(receiver); + if shares > max_shares { + return Err(Error::ExceededMaxMint(ERC4626ExceededMaxMint { + receiver, + shares, + max: max_shares, + })); + } + let assets = self.preview_mint(shares, asset); + self._deposit(msg::sender(), receiver, assets, shares, asset)?; + Ok(assets) + } + + fn max_withdraw(&self, owner: Address, asset: &mut Erc20) -> U256 { + self._convert_to_assets(asset.balance_of(owner), asset) + } + + fn preview_withdraw(&self, assets: U256, asset: &mut Erc20) -> U256 { + self._convert_to_shares(assets, asset) + } + + fn withdraw( + &mut self, + assets: U256, + receiver: Address, + owner: Address, + asset: &mut Erc20, + safe_erc20: &mut SafeErc20, + ) -> Result { + let max_assets = self.max_withdraw(owner, asset); + if assets > max_assets { + return Err(Error::ExceededMaxWithdraw( + ERC4626ExceededMaxWithdraw { owner, assets, max: max_assets }, + )); + } + + let shares = self.preview_redeem(assets, asset); + self._withdraw( + msg::sender(), + receiver, + owner, + assets, + shares, + asset, + safe_erc20, + )?; + Ok(shares) + } + + fn max_redeem(&self, owner: Address, asset: &mut Erc20) -> U256 { + asset.balance_of(owner) + } + + fn preview_redeem(&self, shares: U256, asset: &mut Erc20) -> U256 { + self._convert_to_assets(shares, asset) + } + + fn redeem( + &mut self, + shares: U256, + receiver: Address, + owner: Address, + asset: &mut Erc20, + safe_erc20: &mut SafeErc20, + ) -> Result { + let max_shares = self.max_redeem(owner, asset); + if shares > max_shares { + return Err(Error::ExceededMaxRedeem(ERC4626ExceededMaxRedeem { + owner, + shares, + max: max_shares, + })); + } + + let assets = self.preview_redeem(shares, asset); + self._withdraw( + msg::sender(), + receiver, + owner, + assets, + shares, + asset, + safe_erc20, + )?; + Ok(assets) + } +} + +impl Erc4626 { + fn _convert_to_shares(&self, assets: U256, asset: &Erc20) -> U256 { + let adjusted_total_supply = asset.total_supply() + + U256::from(10u32.pow(self._decimals_offset())); + let adjusted_total_assets = self.total_assets(asset) + U256::from(1); + self._mul_div(assets, adjusted_total_supply, adjusted_total_assets) + } + + fn _convert_to_assets(&self, shares: U256, asset: &Erc20) -> U256 { + let adjusted_total_supply = asset.total_supply() + + U256::from(10u32.pow(self._decimals_offset())); + let adjusted_total_assets = self.total_assets(asset) + U256::from(1); + self._mul_div(shares, adjusted_total_assets, adjusted_total_supply) + } + + fn _mul_div(&self, x: U256, y: U256, dominator: U256) -> U256 { + x.saturating_mul(y).checked_div(dominator).unwrap_or(U256::ZERO) + } + + fn _deposit( + &mut self, + caller: Address, + receiver: Address, + assets: U256, + shares: U256, + asset: &mut Erc20, + ) -> Result<(), Error> { + // If _asset is ERC-777, `transferFrom` can trigger a reentrancy BEFORE + // the transfer happens through the `tokensToSend` hook. On the + // other hand, the `tokenReceived` hook, that is triggered after the + // transfer, calls the vault, which is assumed not malicious. + // + // Conclusion: we need to do the transfer before we mint so that any + // reentrancy would happen before the assets are transferred and + // before the shares are minted, which is a valid state. + // slither-disable-next-line reentrancy-no-eth + + asset._mint(receiver, shares)?; + evm::log(Deposit { sender: caller, owner: receiver, assets, shares }); + Ok(()) + } + + fn _withdraw( + &mut self, + caller: Address, + receiver: Address, + owner: Address, + assets: U256, + shares: U256, + asset: &mut Erc20, + safe_erc20: &mut SafeErc20, + ) -> Result<(), Error> { + if caller != owner { + asset._spend_allowance(owner, caller, shares)?; + } + + // If _asset is ERC-777, `transfer` can trigger a reentrancy AFTER the + // transfer happens through the `tokensReceived` hook. On the + // other hand, the `tokensToSend` hook, that is triggered before the + // transfer, calls the vault, which is assumed not malicious. + // + // Conclusion: we need to do the transfer after the burn so that any + // reentrancy would happen after the shares are burned and after + // the assets are transferred, which is a valid state. + asset._burn(owner, shares)?; + safe_erc20.safe_transfer(contract::address(), receiver, assets)?; + + evm::log(Withdraw { sender: caller, receiver, owner, assets, shares }); + Ok(()) + } + + /// Offset of the decimals of the ERC-20 asset from the decimals of the + /// Vault. + /// + /// This value is used to calculate the number of shares that can be minted + /// for a given amount of assets, and to calculate the number of assets + /// that can be withdrawn for a given amount of shares. + /// + /// The value is set to 0 by default, which means that the decimals of the + /// ERC-20 asset and the Vault are the same. + /// + /// To change this value, you must override this function in your contract. + fn _decimals_offset(&self) -> u32 { + 0 + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use alloy_primitives::{address, U256}; + + use super::Erc4626; + use crate::token::erc20::extensions::erc4626::IERC4626; + + #[motsu::test] + fn max_mint(contract: Erc4626) { + let bob = address!("B0B0cB49ec2e96DF5F5fFB081acaE66A2cBBc2e2"); + let max_mint = contract.max_mint(bob); + assert_eq!(max_mint, U256::MAX); + } + + #[motsu::test] + fn max_deposit(contract: Erc4626) { + let bob = address!("B0B0cB49ec2e96DF5F5fFB081acaE66A2cBBc2e2"); + let max_deposit = contract.max_deposit(bob); + assert_eq!(max_deposit, U256::MAX); + } + + #[motsu::test] + fn convert_to_shares(contract: Erc4626) { + let assets = U256::from(100); + let shares = contract.convert_to_shares(assets); + assert_eq!(shares, U256::from(100)); + } + + #[motsu::test] + fn convert_to_assets(contract: Erc4626) { + let shares = U256::from(100); + let assets = contract.convert_to_assets(shares); + assert_eq!(assets, U256::from(100)); + } +} diff --git a/contracts/src/token/erc20/extensions/mod.rs b/contracts/src/token/erc20/extensions/mod.rs index 549e2989..0b369bf6 100644 --- a/contracts/src/token/erc20/extensions/mod.rs +++ b/contracts/src/token/erc20/extensions/mod.rs @@ -1,12 +1,14 @@ //! Common extensions to the ERC-20 standard. pub mod burnable; pub mod capped; +pub mod erc4626; pub mod flash_mint; pub mod metadata; pub mod permit; pub use burnable::IErc20Burnable; pub use capped::Capped; +pub use erc4626::{Erc4626, IERC4626}; pub use flash_mint::{Erc20FlashMint, IErc3156FlashLender}; pub use metadata::{Erc20Metadata, IErc20Metadata}; pub use permit::Erc20Permit; diff --git a/docs/modules/ROOT/images/erc4626-attack-3a.png b/docs/modules/ROOT/images/erc4626-attack-3a.png new file mode 100644 index 00000000..4cb52237 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack-3a.png differ diff --git a/docs/modules/ROOT/images/erc4626-attack-3b.png b/docs/modules/ROOT/images/erc4626-attack-3b.png new file mode 100644 index 00000000..3dc5256b Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack-3b.png differ diff --git a/docs/modules/ROOT/images/erc4626-attack-6.png b/docs/modules/ROOT/images/erc4626-attack-6.png new file mode 100644 index 00000000..1587fb5c Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack-6.png differ diff --git a/docs/modules/ROOT/images/erc4626-attack.png b/docs/modules/ROOT/images/erc4626-attack.png new file mode 100644 index 00000000..dc059b22 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack.png differ diff --git a/docs/modules/ROOT/images/erc4626-deposit.png b/docs/modules/ROOT/images/erc4626-deposit.png new file mode 100644 index 00000000..b6c75e67 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-deposit.png differ diff --git a/docs/modules/ROOT/images/erc4626-mint.png b/docs/modules/ROOT/images/erc4626-mint.png new file mode 100644 index 00000000..f89ab900 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-mint.png differ diff --git a/docs/modules/ROOT/images/erc4626-rate-linear.png b/docs/modules/ROOT/images/erc4626-rate-linear.png new file mode 100644 index 00000000..09e8045e Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-rate-linear.png differ diff --git a/docs/modules/ROOT/images/erc4626-rate-loglog.png b/docs/modules/ROOT/images/erc4626-rate-loglog.png new file mode 100644 index 00000000..4eb19efe Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-rate-loglog.png differ diff --git a/docs/modules/ROOT/images/erc4626-rate-loglogext.png b/docs/modules/ROOT/images/erc4626-rate-loglogext.png new file mode 100644 index 00000000..127bc7f2 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-rate-loglogext.png differ diff --git a/docs/modules/ROOT/pages/erc20.adoc b/docs/modules/ROOT/pages/erc20.adoc index 869d8ddc..d8e01f5d 100644 --- a/docs/modules/ROOT/pages/erc20.adoc +++ b/docs/modules/ROOT/pages/erc20.adoc @@ -83,4 +83,6 @@ Additionally, there are multiple custom extensions, including: * xref:erc20-permit.adoc[ERC-20 Permit]: gasless approval of tokens (standardized as https://eips.ethereum.org/EIPS/eip-2612[`EIP-2612`]). + * xref:erc4626.adoc[ERC-4626]: standard interface for tokenized vaults. + * xref:erc20-flash-mint.adoc[ERC-20 Flash-Mint]: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as https://eips.ethereum.org/EIPS/eip-3156[`EIP-3156`]). diff --git a/docs/modules/ROOT/pages/erc4626.adoc b/docs/modules/ROOT/pages/erc4626.adoc new file mode 100644 index 00000000..964a6b07 --- /dev/null +++ b/docs/modules/ROOT/pages/erc4626.adoc @@ -0,0 +1,285 @@ += ERC-4626 +:stem: latexmath + +https://eips.ethereum.org/EIPS/eip-4626[ERC-4626] is an extension of xref:erc20.adoc[ERC-20] that proposes a standard interface for token vaults. This standard interface can be used by widely different contracts (including lending markets, aggregators, and intrinsically interest bearing tokens), which brings a number of subtleties. Navigating these potential issues is essential to implementing a compliant and composable token vault. + +We provide a base implementation of ERC-4626 that includes a simple vault. This contract is designed in a way that allows developers to easily re-configure the vault’s behavior, with minimal overrides, while staying compliant. In this guide, we will discuss some security considerations that affect ERC-4626. We will also discuss common customizations of the vault +[[inflation-attack]] +== Security concern: Inflation attack + +=== Visualizing the vault + +In exchange for the assets deposited into an ERC-4626 vault, a user receives shares. These shares can later be burned to redeem the corresponding underlying assets. The number of shares a user gets depends on the amount of assets they put in and on the exchange rate of the vault. This exchange rate is defined by the current liquidity held by the vault. + +- If a vault has 100 tokens to back 200 shares, then each share is worth 0.5 assets. +- If a vault has 200 tokens to back 100 shares, then each share is worth 2.0 assets. + +In other words, the exchange rate can be defined as the slope of the line that passes through the origin and the current number of assets and shares in the vault. Deposits and withdrawals move the vault in this line. + +image::erc4626-rate-linear.png[Exchange rates in linear scale] + +When plotted in log-log scale, the rate is defined similarly, but appears differently (because the point (0,0) is infinitely far away). Rates are represented by "diagonal" lines with different offsets. + +image::erc4626-rate-loglog.png[Exchange rates in logarithmic scale] + +In such a representation, widely different rates can be clearly visible in the same graph. This wouldn't be the case in linear scale. + +image::erc4626-rate-loglogext.png[More exchange rates in logarithmic scale] + +=== The attack + +When depositing tokens, the number of shares a user gets is rounded towards zero. This rounding takes away value from the user in favor of the vault (i.e. in favor of all the current shareholders). This rounding is often negligible because of the amount at stake. If you deposit 1e9 shares worth of tokens, the rounding will have you lose at most 0.0000001% of your deposit. However if you deposit 10 shares worth of tokens, you could lose 10% of your deposit. Even worse, if you deposit <1 share worth of tokens, then you get 0 shares, and you basically made a donation. + +For a given amount of assets, the more shares you receive the safer you are. If you want to limit your losses to at most 1%, you need to receive at least 100 shares. + +image::erc4626-deposit.png[Depositing assets] + +In the figure we can see that for a given deposit of 500 assets, the number of shares we get and the corresponding rounding losses depend on the exchange rate. If the exchange rate is that of the orange curve, we are getting less than a share, so we lose 100% of our deposit. However, if the exchange rate is that of the green curve, we get 5000 shares, which limits our rounding losses to at most 0.02%. + +image::erc4626-mint.png[Minting shares] + +Symmetrically, if we focus on limiting our losses to a maximum of 0.5%, we need to get at least 200 shares. With the green exchange rate that requires just 20 tokens, but with the orange rate that requires 200000 tokens. + +We can clearly see that the blue and green curves correspond to vaults that are safer than the yellow and orange curves. + +The idea of an inflation attack is that an attacker can donate assets to the vault to move the rate curve to the right, and make the vault unsafe. + +image::erc4626-attack.png[Inflation attack without protection] + +Figure 6 shows how an attacker can manipulate the rate of an empty vault. First the attacker must deposit a small amount of tokens (1 token) and follow up with a donation of 1e5 tokens directly to the vault to move the exchange rate "right". This puts the vault in a state where any deposit smaller than 1e5 would be completely lost to the vault. Given that the attacker is the only shareholder (from their donation), the attacker would steal all the tokens deposited. + +An attacker would typically wait for a user to do the first deposit into the vault, and would frontrun that operation with the attack described above. The risk is low, and the size of the "donation" required to manipulate the vault is equivalent to the size of the deposit that is being attacked. + +In math that gives: + +- stem:[a_0] the attacker deposit +- stem:[a_1] the attacker donation +- stem:[u] the user deposit + +[%header,cols=4*] +|=== +| +| Assets +| Shares +| Rate + +| initial +| stem:[0] +| stem:[0] +| - + +| after attacker's deposit +| stem:[a_0] +| stem:[a_0] +| stem:[1] + +| after attacker's donation +| stem:[a_0+a_1] +| stem:[a_0] +| stem:[\frac{a_0}{a_0+a_1}] +|=== + +This means a deposit of stem:[u] will give stem:[\frac{u \times a_0}{a_0 + a_1}] shares. + +For the attacker to dilute that deposit to 0 shares, causing the user to lose all its deposit, it must ensure that + +[stem] +++++ +\frac{u \times a_0}{a_0+a_1} < 1 \iff u < 1 + \frac{a_1}{a_0} +++++ + +Using stem:[a_0 = 1] and stem:[a_1 = u] is enough. So the attacker only needs stem:[u+1] assets to perform a successful attack. + +It is easy to generalize the above results to scenarios where the attacker is going after a smaller fraction of the user's deposit. In order to target stem:[\frac{u}{n}], the user needs to suffer rounding of a similar fraction, which means the user must receive at most stem:[n] shares. This results in: + +[stem] +++++ +\frac{u \times a_0}{a_0+a_1} < n \iff \frac{u}{n} < 1 + \frac{a_1}{a_0} +++++ + +In this scenario, the attack is stem:[n] times less powerful (in how much it is stealing) and costs stem:[n] times less to execute. In both cases, the amount of funds the attacker needs to commit is equivalent to its potential earnings. + +=== Defending with a virtual offset + +The defense we propose is based on the approach used in link:https://github.com/boringcrypto/YieldBox[YieldBox]. It consists of two parts: + +- Use an offset between the "precision" of the representation of shares and assets. Said otherwise, we use more decimal places to represent the shares than the underlying token does to represent the assets. +- Include virtual shares and virtual assets in the exchange rate computation. These virtual assets enforce the conversion rate when the vault is empty. + +These two parts work together in enforcing the security of the vault. First, the increased precision corresponds to a high rate, which we saw is safer as it reduces the rounding error when computing the amount of shares. Second, the virtual assets and shares (in addition to simplifying a lot of the computations) capture part of the donation, making it unprofitable for a developer to perform an attack. + +Following the previous math definitions, we have: + +- stem:[\delta] the vault offset +- stem:[a_0] the attacker deposit +- stem:[a_1] the attacker donation +- stem:[u] the user deposit + +[%header,cols=4*] +|=== +| +| Assets +| Shares +| Rate + +| initial +| stem:[1] +| stem:[10^\delta] +| stem:[10^\delta] + +| after attacker's deposit +| stem:[1+a_0] +| stem:[10^\delta \times (1+a_0)] +| stem:[10^\delta] + +| after attacker's donation +| stem:[1+a_0+a_1] +| stem:[10^\delta \times (1+a_0)] +| stem:[10^\delta \times \frac{1+a_0}{1+a_0+a_1}] +|=== + +One important thing to note is that the attacker only owns a fraction stem:[\frac{a_0}{1 + a_0}] of the shares, so when doing the donation, he will only be able to recover that fraction stem:[\frac{a_1 \times a_0}{1 + a_0}] of the donation. The remaining stem:[\frac{a_1}{1+a_0}] are captured by the vault. + +[stem] +++++ +\mathit{loss} = \frac{a_1}{1+a_0} +++++ + +When the user deposits stem:[u], he receives + +[stem] +++++ +10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} +++++ + +For the attacker to dilute that deposit to 0 shares, causing the user to lose all its deposit, it must ensure that + +[stem] +++++ +10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} < 1 +++++ + +[stem] +++++ +\iff 10^\delta \times u < \frac{1+a_0+a_1}{1+a_0} +++++ + +[stem] +++++ +\iff 10^\delta \times u < 1 + \frac{a_1}{1+a_0} +++++ + +[stem] +++++ +\iff 10^\delta \times u \le \mathit{loss} +++++ + +- If the offset is 0, the attacker loss is at least equal to the user's deposit. +- If the offset is greater than 0, the attacker will have to suffer losses that are orders of magnitude bigger than the amount of value that can hypothetically be stolen from the user. + +This shows that even with an offset of 0, the virtual shares and assets make this attack non profitable for the attacker. Bigger offsets increase the security even further by making any attack on the user extremely wasteful. + +The following figure shows how the offset impacts the initial rate and limits the ability of an attacker with limited funds to inflate it effectively. + +image::erc4626-attack-3a.png[Inflation attack without offset=3] +stem:[\delta = 3], stem:[a_0 = 1], stem:[a_1 = 10^5] + +image::erc4626-attack-3b.png[Inflation attack without offset=3 and an attacker deposit that limits its losses] +stem:[\delta = 3], stem:[a_0 = 100], stem:[a_1 = 10^5] + +image::erc4626-attack-6.png[Inflation attack without offset=6] +stem:[\delta = 6], stem:[a_0 = 1], stem:[a_1 = 10^5] + +[[usage]] +== Usage + +In order to make https://docs.rs/openzeppelin-stylus/0.1.1/openzeppelin_stylus/token/erc20/extensions/erc4262/index.html[`ERC-4262`] methods “external” so that other contracts can call them, you need to implement them by yourself for your final contract as follows: + +[source,rust] +---- +use alloy_primitives::{Address, U256}; +use stylus_sdk::prelude::{entrypoint, public, storage}; +use openzeppelin_stylus::{ + token::erc20::{ + extensions::{ Erc20Metadata, Erc4626, IErc20Metadata, IERC4626}, + Erc20, IErc20, + }, + utils::{introspection::erc165::IErc165, Pausable}, +}; + + +#[entrypoint] +#[storage] +struct Erc4262Example { + #[borrow] + pub erc20: Erc20, + #[borrow] + pub metadata: Erc20Metadata, + #[borrow] + pub erc4626: Erc4626, +} + + +#[public] +#[inherit(Erc20, Erc20Metadata, Erc4626)] +impl Erc4262Example { + fn max_deposit(&self, _receiver: Address) -> U256 { + //self.metadata.decimals() + U256::from(100) + } + // Add token minting feature. +} +---- + +[[fees]] +== Custom behavior: Adding fees to the vault + +In an ERC-4626 vaults, fees can be captured during the deposit/mint and/or during the withdraw/redeem steps. In both cases it is essential to remain compliant with the ERC-4626 requirements with regard to the preview functions. + +For example, if calling `deposit(100, receiver)`, the caller should deposit exactly 100 underlying tokens, including fees, and the receiver should receive a number of shares that matches the value returned by `previewDeposit(100)`. Similarly, `previewMint` should account for the fees that the user will have to pay on top of share's cost. + +As for the `Deposit` event, while this is less clear in the EIP spec itself, there seems to be consensus that it should include the number of assets paid for by the user, including the fees. + +On the other hand, when withdrawing assets, the number given by the user should correspond to what he receives. Any fees should be added to the quote (in shares) performed by `previewWithdraw`. + +The `Withdraw` event should include the number of shares the user burns (including fees) and the number of assets the user actually receives (after fees are deducted). + +The consequence of this design is that both the `Deposit` and `Withdraw` events will describe two exchange rates. The spread between the "Buy-in" and the "Exit" prices correspond to the fees taken by the vault. + +The following example describes how fees proportional to the deposited/withdrawn amount can be implemented: + +[source,rust] +---- +use alloy_primitives::{Address, U256}; +use stylus_sdk::prelude::{entrypoint, public, storage}; +use openzeppelin_stylus::{ + token::erc20::{ + extensions::{ Erc20Metadata, Erc4626, IErc20Metadata, IERC4626}, + Erc20, IErc20, + }, + utils::{introspection::erc165::IErc165, Pausable}, +}; + + +#[entrypoint] +#[storage] +struct Erc4262FeeExample { + #[borrow] + pub erc20: Erc20, + #[borrow] + pub metadata: Erc20Metadata, + #[borrow] + pub erc4626: Erc4626, +} + + +#[public] +#[inherit(Erc20, Erc20Metadata, Erc4626)] +impl Erc4262FeeExample { + fn max_deposit(&self, _receiver: Address) -> U256 { + //self.metadata.decimals() + U256::from(100) + } + // Add token minting feature. +} +---- diff --git a/docs/package-lock.json b/docs/package-lock.json index 63f271de..34dac3e9 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "docs", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docs", - "version": "0.1.0", + "version": "0.1.1", "license": "ISC", "devDependencies": { "@openzeppelin/docs-utils": "^0.1.2" diff --git a/examples/erc4626/Cargo.toml b/examples/erc4626/Cargo.toml new file mode 100644 index 00000000..90416fc0 --- /dev/null +++ b/examples/erc4626/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "erc4626-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[dependencies] +openzeppelin-stylus.workspace = true +alloy-primitives.workspace = true +stylus-sdk.workspace = true + +[dev-dependencies] +alloy.workspace = true +eyre.workspace = true +tokio.workspace = true +e2e.workspace = true + +[features] +e2e = [] + +[lib] +crate-type = ["lib", "cdylib"] diff --git a/examples/erc4626/src/constructor.sol b/examples/erc4626/src/constructor.sol new file mode 100644 index 00000000..d8c3541a --- /dev/null +++ b/examples/erc4626/src/constructor.sol @@ -0,0 +1,22 @@ + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +contract Erc4626Example { + mapping(address account => uint256) private _balances; + mapping(address account => mapping(address spender => uint256)) private _allowances; + uint256 private _totalSupply; + + address private _assetAddress; + uint8 private _underlyingDecimals; + string private _name; + string private _symbol; + + + constructor(address assetAddress_, string memory name_, string memory symbol_) { + _underlyingDecimals = 18; + _assetAddress = assetAddress_; + _name = name_; + _symbol = symbol_; + } +} diff --git a/examples/erc4626/src/lib.rs b/examples/erc4626/src/lib.rs new file mode 100644 index 00000000..d63bdf85 --- /dev/null +++ b/examples/erc4626/src/lib.rs @@ -0,0 +1,123 @@ +#![cfg_attr(not(test), no_main)] +extern crate alloc; + +use alloc::vec::Vec; + +use alloy_primitives::{Address, U256}; +use openzeppelin_stylus::token::erc20::{ + extensions::{Erc20Metadata, Erc4626, IErc20Metadata, IERC4626}, + utils::SafeErc20, + Erc20, +}; +use stylus_sdk::prelude::{entrypoint, public, storage}; + +#[entrypoint] +#[storage] +struct Erc4626Example { + #[borrow] + pub erc20: Erc20, + #[borrow] + pub safe_erc20: SafeErc20, + #[borrow] + pub metadata: Erc20Metadata, + #[borrow] + pub erc4626: Erc4626, +} + +#[public] +#[inherit(Erc20)] +impl Erc4626Example { + fn name(&self) -> String { + self.metadata.name() + } + + fn symbol(&self) -> String { + self.metadata.symbol() + } + + fn asset(&self) -> Address { + self.erc4626.asset() + } + + fn total_assets(&self) -> U256 { + self.erc4626.total_assets(&self.erc20) + } + + fn convert_to_shares(&mut self, assets: U256) -> U256 { + self.erc4626.convert_to_shares(assets, &mut self.erc20) + } + + fn convert_to_assets(&mut self, shares: U256) -> U256 { + self.erc4626.convert_to_assets(shares, &mut self.erc20) + } + + fn preview_deposit(&mut self, assets: U256) -> U256 { + self.erc4626.preview_deposit(assets, &mut self.erc20) + } + + fn deposit( + &mut self, + assets: U256, + receiver: Address, + ) -> Result> { + Ok(self.erc4626.deposit(assets, receiver, &mut self.erc20)?) + } + + fn preview_mint(&mut self, shares: U256) -> U256 { + self.erc4626.preview_mint(shares, &mut self.erc20) + } + + fn mint( + &mut self, + shares: U256, + receiver: Address, + ) -> Result> { + Ok(self.erc4626.mint(shares, receiver, &mut self.erc20)?) + } + + fn max_withdraw(&mut self, owner: Address) -> U256 { + self.erc4626.max_withdraw(owner, &mut self.erc20) + } + + fn preview_withdraw(&mut self, assets: U256) -> U256 { + self.erc4626.preview_withdraw(assets, &mut self.erc20) + } + + fn withdraw( + &mut self, + assets: U256, + receiver: Address, + owner: Address, + ) -> Result> { + Ok(self.erc4626.withdraw( + assets, + receiver, + owner, + &mut self.erc20, + &mut self.safe_erc20, + )?) + } + + fn max_redeem(&mut self, owner: Address) -> U256 { + self.erc4626.max_redeem(owner, &mut self.erc20) + } + + fn preview_redeem(&mut self, shares: U256) -> U256 { + self.erc4626.preview_redeem(shares, &mut self.erc20) + } + + fn redeem( + &mut self, + shares: U256, + receiver: Address, + owner: Address, + ) -> Result> { + Ok(self.erc4626.redeem( + shares, + receiver, + owner, + &mut self.erc20, + &mut self.safe_erc20, + )?) + } +} diff --git a/examples/erc4626/tests/abi/mod.rs b/examples/erc4626/tests/abi/mod.rs new file mode 100644 index 00000000..0457c8cb --- /dev/null +++ b/examples/erc4626/tests/abi/mod.rs @@ -0,0 +1,56 @@ +#![allow(dead_code)] +#![allow(clippy::too_many_arguments)] +use alloy::sol; + +sol!( + #[sol(rpc)] + contract Erc4626 { + + function name() external view returns (string name); + function symbol() external view returns (string symbol); + function totalSupply() external view returns (uint256 totalSupply); + function balanceOf(address account) external view returns (uint256 balance); + function transfer(address recipient, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256 allowance); + function approve(address spender, uint256 amount) external returns (bool); + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); + + function asset() external view returns (address asset); + function totalAssets() external view returns (uint256); + function convertToShares(uint256 assets) external view returns (uint256); + function convertToAssets(uint256 shares) external view returns (uint256); + function maxMint(address) external view returns (uint256); + function maxDeposit(address) external view returns (uint256); + function maxWithdraw(address owner) external view returns (uint256); + function maxRedeem(address owner) external view returns (uint256); + function previewDeposit(uint256 assets) external view returns (uint256); + function previewMint(uint256 shares) external view returns (uint256); + function previewRedeem(uint256 shares) external view returns (uint256); + function previewWithdraw(uint256 assets) external view returns (uint256); + function deposit(uint256 assets, address receiver) external returns (uint256); + function mint(uint256 shares, address receiver) external returns (uint256); + function redeem(uint256 shares, address receiver,address owner) external returns (uint256); + function withdraw(uint256 assets, address receiver,address owner) external returns (uint256); + + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + error ERC20InvalidSender(address sender); + error ERC20InvalidReceiver(address receiver); + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + error ERC20InvalidSpender(address spender); + + error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max); + error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max); + error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max); + error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max); + + #[derive(Debug, PartialEq)] + event Transfer(address indexed from, address indexed to, uint256 value); + #[derive(Debug, PartialEq)] + event Approval(address indexed owner, address indexed spender, uint256 value); + + #[allow(missing_docs)] + event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); + #[allow(missing_docs)] + event Withdraw(address indexed sender,address indexed receiver,address indexed owner,uint256 assets, uint256 shares); + } +); diff --git a/examples/erc4626/tests/erc4626.rs b/examples/erc4626/tests/erc4626.rs new file mode 100644 index 00000000..6a990cf6 --- /dev/null +++ b/examples/erc4626/tests/erc4626.rs @@ -0,0 +1,158 @@ +#![cfg(feature = "e2e")] + +use std::println; + +use abi::Erc4626; +use alloy::{primitives::Address, sol}; +use e2e::{receipt, send, watch, Account, EventExt, ReceiptExt, Revert}; +use eyre::Result; +use mock::{token, token::ERC20Mock}; +use stylus_sdk::contract::address; + +use crate::Erc4626Example::constructorCall; + +const TOKEN_NAME: &str = "Test Token"; +const TOKEN_SYMBOL: &str = "TTK"; + +const VALUT_NAME: &str = "Test Token Valut"; +const VALUT_SYMBOL: &str = "TST Valut"; + +mod abi; +mod mock; + + +sol!("src/constructor.sol"); + +fn ctr(asset_address: Address, name: String, symbol: String) -> constructorCall { + constructorCall { assetAddress_: asset_address, name_: name, symbol_: symbol } +} + +#[e2e::test] +async fn constructs(alice: Account) -> eyre::Result<()> { + let mock_token_address = + token::deploy(&alice.wallet, TOKEN_NAME, TOKEN_SYMBOL).await?; + let contract_addr = alice + .as_deployer() + .with_constructor(ctr( + mock_token_address, + VALUT_NAME.to_string(), + VALUT_SYMBOL.to_string(), + )).deploy().await?.address()?; + let contract = Erc4626::new(contract_addr, &alice.wallet); + let name = contract.name().call().await?.name; + let symbol = contract.symbol().call().await?.symbol; + let asset = contract.asset().call().await?.asset; + assert_eq!(name, VALUT_NAME.to_owned()); + assert_eq!(symbol, VALUT_SYMBOL.to_owned()); + assert_eq!(asset, contract_addr); + Ok(()) +} + +#[e2e::test] +async fn error_when_exceeded_max_deposit( + alice: Account, + bob: Account, +) -> Result<()> { + let mock_token_address = + token::deploy(&alice.wallet, TOKEN_NAME, TOKEN_SYMBOL).await?; + let contract_addr = alice + .as_deployer() + .with_constructor(ctr( + mock_token_address, + VALUT_NAME.to_string(), + VALUT_SYMBOL.to_string(), + )) + .deploy() + .await? + .address()?; + let contract = Erc4626::new(contract_addr, &alice.wallet); + // let alice_addr = alice.address(); + // let bob_addr = bob.address(); + + // let balance = uint!(10_U256); + // let value = uint!(11_U256); + + // let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + // let Erc20::balanceOfReturn { balance: initial_alice_balance } = + // contract_alice.balanceOf(alice_addr).call().await?; + // let Erc20::balanceOfReturn { balance: initial_bob_balance } = + // contract_alice.balanceOf(bob_addr).call().await?; + // let Erc20::totalSupplyReturn { totalSupply: initial_supply } = + // contract_alice.totalSupply().call().await?; + + // let err = send!(contract_alice.transfer(bob_addr, value)) + // .expect_err("should not transfer when insufficient balance"); + // assert!(err.reverted_with(Erc20::ERC20InsufficientBalance { + // sender: alice_addr, + // balance, + // needed: value + // })); + + // let Erc20::balanceOfReturn { balance: alice_balance } = + // contract_alice.balanceOf(alice_addr).call().await?; + // let Erc20::balanceOfReturn { balance: bob_balance } = + // contract_alice.balanceOf(bob_addr).call().await?; + // let Erc20::totalSupplyReturn { totalSupply: supply } = + // contract_alice.totalSupply().call().await?; + + // assert_eq!(initial_alice_balance, alice_balance); + // assert_eq!(initial_bob_balance, bob_balance); + // assert_eq!(initial_supply, supply); + + Ok(()) +} + +// #[e2e::test] +// async fn error_when_exceeded_max_mint( +// alice: Account, +// bob: Account, +// ) -> Result<()> { +// let contract_addr = alice +// .as_deployer() +// .with_default_constructor::() +// .deploy() +// .await? +// .address()?; +// let contract_alice = Erc4626::new(contract_addr, &alice.wallet); +// let alice_addr = alice.address(); +// let bob_addr = bob.address(); + +// Ok(()) +// } + +// #[e2e::test] +// async fn error_when_exceeded_max_withdraw( +// alice: Account, +// bob: Account, +// ) -> Result<()> { +// let contract_addr = alice +// .as_deployer() +// .with_default_constructor::() +// .deploy() +// .await? +// .address()?; +// let contract_alice = Erc4626::new(contract_addr, &alice.wallet); +// let alice_addr = alice.address(); +// let bob_addr = bob.address(); + +// Ok(()) +// } + +// #[e2e::test] +// async fn error_when_exceeded_max_redeem( +// alice: Account, +// bob: Account, +// ) -> Result<()> { +// let contract_addr = alice +// .as_deployer() +// .with_default_constructor::() +// .deploy() +// .await? +// .address()?; +// let contract_alice = Erc4626::new(contract_addr, &alice.wallet); +// let alice_addr = alice.address(); +// let bob_addr = bob.address(); + +// Ok(()) +// } diff --git a/examples/erc4626/tests/mock/mod.rs b/examples/erc4626/tests/mock/mod.rs new file mode 100644 index 00000000..79c66ba6 --- /dev/null +++ b/examples/erc4626/tests/mock/mod.rs @@ -0,0 +1 @@ +pub mod token; diff --git a/examples/erc4626/tests/mock/token.rs b/examples/erc4626/tests/mock/token.rs new file mode 100644 index 00000000..f774fd2e --- /dev/null +++ b/examples/erc4626/tests/mock/token.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +#![cfg(feature = "e2e")] +use alloy::{ + primitives::{Address, FixedBytes, U256}, + sol, +}; +use e2e::Wallet; +use stylus_sdk::{abi::Bytes, function_selector}; + +sol! { + #[allow(missing_docs)] + // Built with Remix IDE; solc 0.8.24+commit.e11b9ed9 + #[sol(rpc, bytecode="608060405234801561000f575f80fd5b50604051611326380380611326833981810160405281019061003191906101dd565b828281600390816100429190610469565b5080600490816100529190610469565b505050505050610538565b5f604051905090565b5f80fd5b5f80fd5b5f80fd5b5f80fd5b5f601f19601f8301169050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b6100bc82610076565b810181811067ffffffffffffffff821117156100db576100da610086565b5b80604052505050565b5f6100ed61005d565b90506100f982826100b3565b919050565b5f67ffffffffffffffff82111561011857610117610086565b5b61012182610076565b9050602081019050919050565b8281835e5f83830152505050565b5f61014e610149846100fe565b6100e4565b90508281526020810184848401111561016a57610169610072565b5b61017584828561012e565b509392505050565b5f82601f8301126101915761019061006e565b5b81516101a184826020860161013c565b91505092915050565b5f819050919050565b6101bc816101aa565b81146101c6575f80fd5b50565b5f815190506101d7816101b3565b92915050565b5f805f606084860312156101f4576101f3610066565b5b5f84015167ffffffffffffffff8111156102115761021061006a565b5b61021d8682870161017d565b935050602084015167ffffffffffffffff81111561023e5761023d61006a565b5b61024a8682870161017d565b925050604061025b868287016101c9565b9150509250925092565b5f81519050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f60028204905060018216806102b357607f821691505b6020821081036102c6576102c561026f565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f600883026103287fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff826102ed565b61033286836102ed565b95508019841693508086168417925050509392505050565b5f819050919050565b5f61036d610368610363846101aa565b61034a565b6101aa565b9050919050565b5f819050919050565b61038683610353565b61039a61039282610374565b8484546102f9565b825550505050565b5f90565b6103ae6103a2565b6103b981848461037d565b505050565b5b818110156103dc576103d15f826103a6565b6001810190506103bf565b5050565b601f821115610421576103f2816102cc565b6103fb846102de565b8101602085101561040a578190505b61041e610416856102de565b8301826103be565b50505b505050565b5f82821c905092915050565b5f6104415f1984600802610426565b1980831691505092915050565b5f6104598383610432565b9150826002028217905092915050565b61047282610265565b67ffffffffffffffff81111561048b5761048a610086565b5b610495825461029c565b6104a08282856103e0565b5f60209050601f8311600181146104d1575f84156104bf578287015190505b6104c9858261044e565b865550610530565b601f1984166104df866102cc565b5f5b82811015610506578489015182556001820191506020850194506020810190506104e1565b86831015610523578489015161051f601f891682610432565b8355505b6001600288020188555050505b505050505050565b610de1806105455f395ff3fe608060405234801561000f575f80fd5b5060043610610091575f3560e01c8063313ce56711610064578063313ce5671461013157806370a082311461014f57806395d89b411461017f578063a9059cbb1461019d578063dd62ed3e146101cd57610091565b806306fdde0314610095578063095ea7b3146100b357806318160ddd146100e357806323b872dd14610101575b5f80fd5b61009d6101fd565b6040516100aa9190610a5a565b60405180910390f35b6100cd60048036038101906100c89190610b0b565b61028d565b6040516100da9190610b63565b60405180910390f35b6100eb6102af565b6040516100f89190610b8b565b60405180910390f35b61011b60048036038101906101169190610ba4565b6102b8565b6040516101289190610b63565b60405180910390f35b6101396102e6565b6040516101469190610c0f565b60405180910390f35b61016960048036038101906101649190610c28565b6102ee565b6040516101769190610b8b565b60405180910390f35b610187610333565b6040516101949190610a5a565b60405180910390f35b6101b760048036038101906101b29190610b0b565b6103c3565b6040516101c49190610b63565b60405180910390f35b6101e760048036038101906101e29190610c53565b6103e5565b6040516101f49190610b8b565b60405180910390f35b60606003805461020c90610cbe565b80601f016020809104026020016040519081016040528092919081815260200182805461023890610cbe565b80156102835780601f1061025a57610100808354040283529160200191610283565b820191905f5260205f20905b81548152906001019060200180831161026657829003601f168201915b5050505050905090565b5f80610297610467565b90506102a481858561046e565b600191505092915050565b5f600254905090565b5f806102c2610467565b90506102cf858285610480565b6102da858585610512565b60019150509392505050565b5f6012905090565b5f805f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20549050919050565b60606004805461034290610cbe565b80601f016020809104026020016040519081016040528092919081815260200182805461036e90610cbe565b80156103b95780601f10610390576101008083540402835291602001916103b9565b820191905f5260205f20905b81548152906001019060200180831161039c57829003601f168201915b5050505050905090565b5f806103cd610467565b90506103da818585610512565b600191505092915050565b5f60015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2054905092915050565b5f33905090565b61047b8383836001610602565b505050565b5f61048b84846103e5565b90507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff811461050c57818110156104fd578281836040517ffb8f41b20000000000000000000000000000000000000000000000000000000081526004016104f493929190610cfd565b60405180910390fd5b61050b84848484035f610602565b5b50505050565b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610582575f6040517f96c6fd1e0000000000000000000000000000000000000000000000000000000081526004016105799190610d32565b60405180910390fd5b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036105f2575f6040517fec442f050000000000000000000000000000000000000000000000000000000081526004016105e99190610d32565b60405180910390fd5b6105fd8383836107d1565b505050565b5f73ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff1603610672575f6040517fe602df050000000000000000000000000000000000000000000000000000000081526004016106699190610d32565b60405180910390fd5b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16036106e2575f6040517f94280d620000000000000000000000000000000000000000000000000000000081526004016106d99190610d32565b60405180910390fd5b8160015f8673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f208190555080156107cb578273ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925846040516107c29190610b8b565b60405180910390a35b50505050565b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610821578060025f8282546108159190610d78565b925050819055506108ef565b5f805f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20549050818110156108aa578381836040517fe450d38c0000000000000000000000000000000000000000000000000000000081526004016108a193929190610cfd565b60405180910390fd5b8181035f808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2081905550505b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610936578060025f8282540392505081905550610980565b805f808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f82825401925050819055505b8173ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef836040516109dd9190610b8b565b60405180910390a3505050565b5f81519050919050565b5f82825260208201905092915050565b8281835e5f83830152505050565b5f601f19601f8301169050919050565b5f610a2c826109ea565b610a3681856109f4565b9350610a46818560208601610a04565b610a4f81610a12565b840191505092915050565b5f6020820190508181035f830152610a728184610a22565b905092915050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610aa782610a7e565b9050919050565b610ab781610a9d565b8114610ac1575f80fd5b50565b5f81359050610ad281610aae565b92915050565b5f819050919050565b610aea81610ad8565b8114610af4575f80fd5b50565b5f81359050610b0581610ae1565b92915050565b5f8060408385031215610b2157610b20610a7a565b5b5f610b2e85828601610ac4565b9250506020610b3f85828601610af7565b9150509250929050565b5f8115159050919050565b610b5d81610b49565b82525050565b5f602082019050610b765f830184610b54565b92915050565b610b8581610ad8565b82525050565b5f602082019050610b9e5f830184610b7c565b92915050565b5f805f60608486031215610bbb57610bba610a7a565b5b5f610bc886828701610ac4565b9350506020610bd986828701610ac4565b9250506040610bea86828701610af7565b9150509250925092565b5f60ff82169050919050565b610c0981610bf4565b82525050565b5f602082019050610c225f830184610c00565b92915050565b5f60208284031215610c3d57610c3c610a7a565b5b5f610c4a84828501610ac4565b91505092915050565b5f8060408385031215610c6957610c68610a7a565b5b5f610c7685828601610ac4565b9250506020610c8785828601610ac4565b9150509250929050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f6002820490506001821680610cd557607f821691505b602082108103610ce857610ce7610c91565b5b50919050565b610cf781610a9d565b82525050565b5f606082019050610d105f830186610cee565b610d1d6020830185610b7c565b610d2a6040830184610b7c565b949350505050565b5f602082019050610d455f830184610cee565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610d8282610ad8565b9150610d8d83610ad8565b9250828201905080821115610da557610da4610d4b565b5b9291505056fea2646970667358221220f4fc15ab530d6cd636e13fe19bd56325e1ba241143214ecc638450e6d815ba0564736f6c634300081a0033")] + contract ERC20Mock is ERC20 { + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + } +} + +pub async fn deploy( + wallet: &Wallet, + token_name: &str, + token_symbol: &str, +) -> eyre::Result
{ + let contract = ERC20Mock::deploy( + wallet, + token_name.to_string(), + token_symbol.to_string(), + ) + .await?; + Ok(*contract.address()) +}