diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f22146905..d28780bcf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,7 @@ jobs: # https://twitter.com/jonhoo/status/1571290371124260865 - name: Run unit tests - run: cargo nextest run --locked --features std --all-targets + run: cargo nextest run --locked --features std --all-targets -p openzeppelin-stylus -p openzeppelin-stylus-proc -p openzeppelin-crypto # https://github.com/rust-lang/cargo/issues/6669 - name: Run doc tests @@ -93,7 +93,7 @@ jobs: run: cargo generate-lockfile - name: Run unit tests - run: cargo nextest run --locked --features std --all-targets + run: cargo nextest run --locked --features std --all-targets -p openzeppelin-stylus -p openzeppelin-stylus-proc -p openzeppelin-crypto coverage: # Use llvm-cov to build and collect coverage and outputs in a format that # is compatible with codecov.io. @@ -141,7 +141,7 @@ jobs: run: cargo generate-lockfile - name: Cargo llvm-cov - run: cargo llvm-cov --locked --features std --lcov --output-path lcov.info + run: cargo llvm-cov --locked --features std --lcov --output-path lcov.info -p openzeppelin-stylus -p openzeppelin-stylus-proc -p openzeppelin-crypto - name: Record Rust version run: echo "RUST=$(rustc --version)" >> "$GITHUB_ENV" diff --git a/.gitignore b/.gitignore index 28adb4fad..692f2637b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ docs/build/ **/.DS_Store **/nitro-testnode + +lcov.info + +lcov.infos diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2808451..ffb14b380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- +- `Erc20FlashMint` extension. #407 ### Changed -- Use `AddAssignUnchecked` and `SubAssignUnchecked` in `erc20::_update` calculations. #467 +- Use `AddAssignUnchecked` and `SubAssignUnchecked` in `erc20::_update`. #467 ### Changed (Breaking) -- `Nonce::use_nonce` now panics on exceeding `U256::MAX`. #467 +- Add full support for reentrancy (changed `VestingWallet` signature for some functions). #407 +- `Nonce::use_nonce` panics on exceeding `U256::MAX`. #467 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 19d6f3afa..142588479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1600,6 +1600,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "erc20-flash-mint-example" +version = "0.2.0-alpha.2" +dependencies = [ + "alloy", + "alloy-primitives", + "e2e", + "eyre", + "openzeppelin-stylus", + "stylus-sdk", + "tokio", +] + [[package]] name = "erc20-permit-example" version = "0.2.0-alpha.2" @@ -2682,7 +2695,7 @@ dependencies = [ ] [[package]] -name = "ownable-two-step" +name = "ownable-two-step-example" version = "0.2.0-alpha.2" dependencies = [ "alloy", diff --git a/Cargo.toml b/Cargo.toml index 0357aeef7..0f3806f98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "lib/e2e-proc", "examples/erc20", "examples/erc20-permit", + "examples/erc20-flash-mint", "examples/erc721", "examples/erc721-consecutive", "examples/erc721-metadata", @@ -31,6 +32,7 @@ default-members = [ "lib/e2e-proc", "examples/erc20", "examples/erc20-permit", + "examples/erc20-flash-mint", "examples/erc721", "examples/erc721-consecutive", "examples/erc721-metadata", diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index e305a5de9..1b4946046 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -28,6 +28,7 @@ rand.workspace = true # features, because this crate is meant to be used in a `no_std` environment. # Currently, the std feature is only used for testing purposes. std = [] +reentrant = ["stylus-sdk/reentrant"] [lib] crate-type = ["lib"] diff --git a/contracts/src/finance/vesting_wallet.rs b/contracts/src/finance/vesting_wallet.rs index 03e25c3b8..5f904a93a 100644 --- a/contracts/src/finance/vesting_wallet.rs +++ b/contracts/src/finance/vesting_wallet.rs @@ -253,7 +253,7 @@ pub trait IVestingWallet { /// /// # Arguments /// - /// * `&self` - Read access to the contract's state. + /// * `&mut self` - Write access to the contract's state. /// * `token` - Address of the releasable token. /// /// # Errors @@ -266,7 +266,8 @@ pub trait IVestingWallet { /// If total allocation exceeds `U256::MAX`. /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. #[selector(name = "releasable")] - fn releasable_erc20(&self, token: Address) -> Result; + fn releasable_erc20(&mut self, token: Address) + -> Result; /// Release the native tokens (Ether) that have already vested. /// @@ -335,7 +336,7 @@ pub trait IVestingWallet { /// /// # Arguments /// - /// * `&self` - Read access to the contract's state. + /// * `&mut self` - Write access to the contract's state. /// * `token` - Address of the token being released. /// * `timestamp` - Point in time for which to check the vested amount. /// @@ -350,7 +351,7 @@ pub trait IVestingWallet { /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. #[selector(name = "vestedAmount")] fn vested_amount_erc20( - &self, + &mut self, token: Address, timestamp: u64, ) -> Result; @@ -410,7 +411,10 @@ impl IVestingWallet for VestingWallet { } #[selector(name = "releasable")] - fn releasable_erc20(&self, token: Address) -> Result { + fn releasable_erc20( + &mut self, + token: Address, + ) -> Result { let vested = self.vested_amount_erc20(token, block::timestamp())?; // SAFETY: total vested amount is by definition greater than or equal to // the released amount. @@ -467,13 +471,13 @@ impl IVestingWallet for VestingWallet { #[selector(name = "vestedAmount")] fn vested_amount_erc20( - &self, + &mut self, token: Address, timestamp: u64, ) -> Result { let erc20 = IErc20::new(token); let balance = erc20 - .balance_of(Call::new(), contract::address()) + .balance_of(Call::new_in(self), contract::address()) .map_err(|_| InvalidToken { token })?; let total_allocation = balance diff --git a/contracts/src/token/erc1155/extensions/uri_storage.rs b/contracts/src/token/erc1155/extensions/uri_storage.rs index f1d90ea67..b3e03c247 100644 --- a/contracts/src/token/erc1155/extensions/uri_storage.rs +++ b/contracts/src/token/erc1155/extensions/uri_storage.rs @@ -96,7 +96,7 @@ mod tests { use stylus_sdk::prelude::storage; use super::Erc1155UriStorage; - use crate::token::erc1155::{extensions::Erc1155MetadataUri, Erc1155}; + use crate::token::erc1155::extensions::Erc1155MetadataUri; fn random_token_id() -> U256 { let num: u32 = rand::random(); diff --git a/contracts/src/token/erc20/extensions/flash_mint.rs b/contracts/src/token/erc20/extensions/flash_mint.rs new file mode 100644 index 000000000..b6df70a9f --- /dev/null +++ b/contracts/src/token/erc20/extensions/flash_mint.rs @@ -0,0 +1,447 @@ +//! Implementation of the ERC-3156 Flash loans extension, as defined in +//! [ERC-3156]. +//! +//! Adds the [`IErc3156FlashLender::flash_loan`] method, which provides flash +//! loan support at the token level. By default there is no fee, but this can be +//! changed by overriding [`IErc3156FlashLender::flash_loan`]. +//! +//! NOTE: When this extension is used along with the +//! [`crate::token::erc20::extensions::Capped`] extension, +//! [`IErc3156FlashLender::max_flash_loan`] will not correctly reflect the +//! maximum that can be flash minted. We recommend overriding +//! [`IErc3156FlashLender::max_flash_loan`] so that it correctly reflects the +//! supply cap. +//! +//! [ERC-3156]: https://eips.ethereum.org/EIPS/eip-3156 + +// TODO: once ERC20Votes is implemented, include it in the comment above next to +// ERC20Capped. + +use alloy_primitives::{Address, U256}; +use stylus_sdk::{ + abi::Bytes, + call::Call, + contract, msg, + prelude::*, + storage::{StorageAddress, StorageU256, TopLevelStorage}, +}; + +use crate::token::erc20::{self, Erc20, IErc20}; + +/// The expected value returned from [`IERC3156FlashBorrower::on_flash_loan`]. +const BORROWER_CALLBACK_VALUE: [u8; 32] = keccak_const::Keccak256::new() + .update("ERC3156FlashBorrower.onFlashLoan".as_bytes()) + .finalize(); + +pub use sol::*; +mod sol { + #![cfg_attr(coverage_nightly, coverage(off))] + use alloy_sol_macro::sol; + + sol! { + /// Indicate that the loan token is not supported or valid. + /// + /// * `token` - Address of the unsupported token. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC3156UnsupportedToken(address token); + + /// Indicate an error related to the loan value exceeding the maximum. + /// + /// * `max_loan` - Maximum loan value. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC3156ExceededMaxLoan(uint256 max_loan); + + /// Indicate that the receiver of a flashloan is not a valid [`IERC3156FlashBorrower::on_flash_loan`] implementer. + /// + /// * `receiver` - Address to which tokens are being transferred. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC3156InvalidReceiver(address receiver); + } +} + +/// An [`Erc20FlashMint`] extension error. +#[derive(SolidityError, Debug)] +pub enum Error { + /// Indicate that the loan token is not supported or valid. + UnsupportedToken(ERC3156UnsupportedToken), + /// Indicate an error related to the loan value exceeding the maximum. + ExceededMaxLoan(ERC3156ExceededMaxLoan), + /// Indicate that the receiver of a flashloan is not a valid + /// [`IERC3156FlashBorrower::on_flash_loan`] implementer. + InvalidReceiver(ERC3156InvalidReceiver), + /// Error type from [`Erc20`] contract [`erc20::Error`]. + Erc20(erc20::Error), +} + +pub use borrower::IERC3156FlashBorrower; +mod borrower { + #![allow(missing_docs)] + #![cfg_attr(coverage_nightly, coverage(off))] + use stylus_sdk::stylus_proc::sol_interface; + + sol_interface! { + /// Interface of the ERC-3156 FlashBorrower, as defined in [ERC-3156]. + /// + /// [ERC-3156]: https://eips.ethereum.org/EIPS/eip-3156 + interface IERC3156FlashBorrower { + /// Receives a flash loan. + /// + /// To indicate successful handling of the flash loan, this function should return + /// the `keccak256` hash of "ERC3156FlashBorrower.onFlashLoan". + /// + /// # Arguments + /// + /// * `initiator` - The initiator of the flash loan. + /// * `token` - The token to be flash loaned. + /// * `amount` - The amount of tokens lent. + /// * `fee` - The additional amount of tokens to repay. + /// * `data` - Arbitrary data structure, intended to contain user-defined parameters. + #[allow(missing_docs)] + function onFlashLoan( + address initiator, + address token, + uint256 amount, + uint256 fee, + bytes calldata data + ) external returns (bytes32); + } + } +} + +/// State of the [`Erc20FlashMint`] Contract. +#[storage] +pub struct Erc20FlashMint { + /// Fee applied when doing flash loans. + pub flash_fee_value: StorageU256, + /// Receiver address of the flash fee. + pub flash_fee_receiver_address: StorageAddress, +} + +/// 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 Erc20FlashMint {} + +/// Interface of the ERC-3156 Flash Lender, as defined in [ERC-3156]. +/// +/// [ERC-3156]: https://eips.ethereum.org/EIPS/eip-3156 +pub trait IErc3156FlashLender { + /// The error type associated to this trait implementation. + type Error: Into>; + + /// Returns the maximum amount of tokens available for loan. + /// + /// NOTE: This function does not consider any form of supply cap, so in case + /// it's used in a token with a cap like + /// [`crate::token::erc20::extensions::Capped`], make sure to override this + /// function to integrate the cap instead of [`U256::MAX`]. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `token` - The address of the token that is requested. + /// * `erc20` - Read access to an [`Erc20`] contract. + /// + /// # Examples + /// + /// In order to have [`IErc3156FlashLender::max_flash_loan`] exposed in ABI, + /// you need to do this manually. + /// + /// ```rust,ignore + /// fn max_flash_loan(&self, token: Address) -> U256 { + /// self.erc20_flash_mint.max_flash_loan(token, &self.erc20) + /// } + fn max_flash_loan(&self, token: Address, erc20: &Erc20) -> U256; + + /// Returns the fee applied when doing flash loans. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `token` - The token to be flash loaned. + /// * `value` - The amount of tokens to be loaned. + /// + /// # Errors + /// + /// If the token is not supported, then the error + /// [`Error::UnsupportedToken`] is returned. + /// + /// # Examples + /// + /// In order to have [`IErc3156FlashLender::flash_fee`] exposed in ABI, you + /// need to do this manually. + /// + /// ```rust,ignore + /// fn flash_fee(&self, token: Address, value: U256) -> Result> { + /// Ok(self.erc20_flash_mint.flash_fee(token, value)?) + /// } + fn flash_fee( + &self, + token: Address, + value: U256, + ) -> Result; + + /// Performs a flash loan. + /// + /// New tokens are minted and sent to the `receiver`, who is required to + /// implement the [`IERC3156FlashBorrower`] interface. By the end of the + /// flash loan, the receiver is expected to own value + fee tokens and have + /// them approved back to the token contract itself so they can be burned. + /// + /// Returns a boolean value indicating whether the operation succeeded. + /// + /// In order to have [`IErc3156FlashLender::flash_loan`] exposed in ABI, you + /// need to do this manually. + /// + /// ```rust,ignore + /// fn flash_loan( + /// &mut self, + /// receiver: Address, + /// token: Address, + /// value: U256, + /// data: Bytes, + /// ) -> Result> { + /// Ok(self.erc20_flash_mint.flash_loan( + /// receiver, + /// token, + /// value, + /// data, + /// &mut self.erc20, + /// )?) + /// } + /// ``` + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `receiver` - The receiver of the flash loan. Should implement the + /// [`IERC3156FlashBorrower::on_flash_loan`] interface. + /// * `token` - The token to be flash loaned. Only [`contract::address()`] + /// is supported. + /// * `value` - The amount of tokens to be loaned. + /// * `data` - Arbitrary data that is passed to the receiver. + /// * `erc20` - Write access to an [`Erc20`] contract. + /// + /// # Errors + /// + /// * If the `value` is greater than the value returned by + /// [`IErc3156FlashLender::max_flash_loan`], then the error + /// [`Error::ExceededMaxLoan`] is returned. + /// * If `token` is not supported, then the error + /// [`Error::UnsupportedToken`] is returned. + /// * If the `token` address is not a contract, then the error + /// [`Error::InvalidReceiver`] is returned. + /// * If the contract fails to execute the call, then the error + /// [`Error::InvalidReceiver`] is returned. + /// * If the receiver does not return [`BORROWER_CALLBACK_VALUE`], then the + /// error [`Error::InvalidReceiver`] is returned. + /// + /// # Events + /// + /// Emits an [`erc20::Transfer`] event. + /// Emits an [`erc20::Approval`] event. + /// + /// # Panics + /// + /// If the new (temporary) total supply exceeds `U256::MAX`. + /// If the sum of the loan value and fee exceeds the maximum value of + /// `U256::MAX`. + fn flash_loan( + &mut self, + receiver: Address, + token: Address, + value: U256, + data: Bytes, + erc20: &mut Erc20, + ) -> Result; +} + +impl IErc3156FlashLender for Erc20FlashMint { + type Error = Error; + + fn max_flash_loan(&self, token: Address, erc20: &Erc20) -> U256 { + if token == contract::address() { + U256::MAX - erc20.total_supply() + } else { + U256::MIN + } + } + + fn flash_fee( + &self, + token: Address, + _value: U256, + ) -> Result { + if token == contract::address() { + Ok(self.flash_fee_value.get()) + } else { + Err(Error::UnsupportedToken(ERC3156UnsupportedToken { token })) + } + } + + // This function can reenter, but it doesn't pose a risk because it always + // preserves the property that the amount minted at the beginning is always + // recovered and burned at the end, or else the entire function will revert. + fn flash_loan( + &mut self, + receiver: Address, + token: Address, + value: U256, + data: Bytes, + erc20: &mut Erc20, + ) -> Result { + let max_loan = self.max_flash_loan(token, erc20); + if value > max_loan { + return Err(Error::ExceededMaxLoan(ERC3156ExceededMaxLoan { + max_loan, + })); + } + + let fee = self.flash_fee(token, value)?; + if !Address::has_code(&receiver) { + return Err(Error::InvalidReceiver(ERC3156InvalidReceiver { + receiver, + })); + } + erc20._mint(receiver, value)?; + let loan_receiver = IERC3156FlashBorrower::new(receiver); + let loan_return = loan_receiver + .on_flash_loan( + Call::new_in(self), + msg::sender(), + token, + value, + fee, + data.to_vec().into(), + ) + .map_err(|_| { + Error::InvalidReceiver(ERC3156InvalidReceiver { receiver }) + })?; + if loan_return != BORROWER_CALLBACK_VALUE { + return Err(Error::InvalidReceiver(ERC3156InvalidReceiver { + receiver, + })); + } + + let allowance = value + .checked_add(fee) + .expect("allowance should not exceed `U256::MAX`"); + erc20._spend_allowance(receiver, contract::address(), allowance)?; + + let flash_fee_receiver = self.flash_fee_receiver_address.get(); + + if fee.is_zero() || flash_fee_receiver.is_zero() { + erc20._burn(receiver, allowance)?; + } else { + erc20._burn(receiver, value)?; + erc20._transfer(receiver, flash_fee_receiver, fee)?; + } + + Ok(true) + } +} + +// TODO: unignore all tests once it's possible to mock contract address. +// NOTE: double check that the tests assert the correct and expected things. +#[cfg(all(test, feature = "std"))] +mod tests { + use alloy_primitives::{address, uint, Address, U256}; + use stylus_sdk::msg; + + use super::{Erc20, Erc20FlashMint, Error, IErc3156FlashLender}; + + const ALICE: Address = address!("A11CEacF9aa32246d767FCCD72e02d6bCbcC375d"); + const TOKEN_ADDRESS: Address = + address!("dce82b5f92c98f27f116f70491a487effdb6a2a9"); + const INVALID_TOKEN_ADDRESS: Address = + address!("dce82b5f92c98f27f116f70491a487effdb6a2aa"); + + #[motsu::test] + #[ignore] + fn max_flash_loan_token_match(contract: Erc20FlashMint) { + let erc20 = Erc20::default(); + let max_flash_loan = contract.max_flash_loan(TOKEN_ADDRESS, &erc20); + assert_eq!(max_flash_loan, U256::MAX); + } + + #[motsu::test] + #[ignore] + fn max_flash_loan_token_mismatch(contract: Erc20FlashMint) { + let erc20 = Erc20::default(); + let max_flash_loan = + contract.max_flash_loan(INVALID_TOKEN_ADDRESS, &erc20); + assert_eq!(max_flash_loan, U256::MIN); + } + + #[motsu::test] + #[ignore] + fn max_flash_loan_when_token_minted(contract: Erc20FlashMint) { + let mut erc20 = Erc20::default(); + erc20._mint(msg::sender(), uint!(10000_U256)).unwrap(); + let max_flash_loan = contract.max_flash_loan(TOKEN_ADDRESS, &erc20); + assert_eq!(max_flash_loan, U256::MAX - uint!(10000_U256)); + } + + #[motsu::test] + #[ignore] + fn flash_fee(contract: Erc20FlashMint) { + let flash_fee = + contract.flash_fee(TOKEN_ADDRESS, uint!(1000_U256)).unwrap(); + assert_eq!(flash_fee, U256::MIN); + } + + #[motsu::test] + #[ignore] + fn error_flash_fee_when_invalid_token(contract: Erc20FlashMint) { + let result = + contract.flash_fee(INVALID_TOKEN_ADDRESS, uint!(1000_U256)); + assert!(matches!(result, Err(Error::UnsupportedToken(_)))); + } + + #[motsu::test] + #[ignore] + fn error_flash_loan_when_exceeded_max_loan(contract: Erc20FlashMint) { + let mut erc20 = Erc20::default(); + let _ = erc20._mint(msg::sender(), uint!(10000_U256)); + let result = contract.flash_loan( + msg::sender(), + TOKEN_ADDRESS, + U256::MAX, + vec![0, 1].into(), + &mut erc20, + ); + assert!(matches!(result, Err(Error::ExceededMaxLoan(_)))); + } + + #[motsu::test] + #[ignore] + fn error_flash_loan_when_zero_receiver_address(contract: Erc20FlashMint) { + let mut erc20 = Erc20::default(); + let invalid_reciver = Address::ZERO; + let result = contract.flash_loan( + invalid_reciver, + TOKEN_ADDRESS, + uint!(1000_U256), + vec![0, 1].into(), + &mut erc20, + ); + assert_eq!(result.is_err(), true); + } + + #[motsu::test] + #[ignore] + fn error_flash_loan_when_invalid_receiver(contract: Erc20FlashMint) { + let mut erc20 = Erc20::default(); + let result = contract.flash_loan( + ALICE, + TOKEN_ADDRESS, + uint!(1000_U256), + vec![0, 1].into(), + &mut erc20, + ); + assert_eq!(result.is_err(), true); + } +} diff --git a/contracts/src/token/erc20/extensions/mod.rs b/contracts/src/token/erc20/extensions/mod.rs index beaa80ecb..549e29894 100644 --- a/contracts/src/token/erc20/extensions/mod.rs +++ b/contracts/src/token/erc20/extensions/mod.rs @@ -1,10 +1,12 @@ //! Common extensions to the ERC-20 standard. pub mod burnable; pub mod capped; +pub mod flash_mint; pub mod metadata; pub mod permit; pub use burnable::IErc20Burnable; pub use capped::Capped; +pub use flash_mint::{Erc20FlashMint, IErc3156FlashLender}; pub use metadata::{Erc20Metadata, IErc20Metadata}; pub use permit::Erc20Permit; diff --git a/contracts/src/token/erc20/mod.rs b/contracts/src/token/erc20/mod.rs index 92bc99f55..9d81b4096 100644 --- a/contracts/src/token/erc20/mod.rs +++ b/contracts/src/token/erc20/mod.rs @@ -559,6 +559,10 @@ impl Erc20 { /// /// If not enough allowance is available, then the error /// [`Error::InsufficientAllowance`] is returned. + /// + /// # Events + /// + /// Emits an [`Approval`] event. pub fn _spend_allowance( &mut self, owner: Address, diff --git a/contracts/src/token/erc20/utils/safe_erc20.rs b/contracts/src/token/erc20/utils/safe_erc20.rs index 20bb84e9e..1341b3daa 100644 --- a/contracts/src/token/erc20/utils/safe_erc20.rs +++ b/contracts/src/token/erc20/utils/safe_erc20.rs @@ -15,7 +15,6 @@ pub use sol::*; use stylus_sdk::{ call::{MethodError, RawCall}, contract::address, - evm::gas_left, function_selector, prelude::storage, storage::TopLevelStorage, @@ -23,7 +22,7 @@ use stylus_sdk::{ types::AddressVM, }; -use crate::token::erc20; +use crate::{token::erc20, utils::ReentrantCallHandler}; #[cfg_attr(coverage_nightly, coverage(off))] mod sol { @@ -351,9 +350,8 @@ impl SafeErc20 { } match RawCall::new() - .gas(gas_left()) .limit_return_data(0, 32) - .call(token, &call.abi_encode()) + .call_with_reentrant_handling(token, &call.abi_encode()) { Ok(data) if data.is_empty() || Self::encodes_true(&data) => Ok(()), _ => Err(SafeErc20FailedOperation { token }.into()), @@ -380,17 +378,16 @@ impl SafeErc20 { } let call = IErc20::allowanceCall { owner: address(), spender }; - let allowance = RawCall::new() - .gas(gas_left()) + let result = RawCall::new() .limit_return_data(0, 32) - .call(token, &call.abi_encode()) + .call_with_reentrant_handling(token, &call.abi_encode()) .map_err(|_| { Error::SafeErc20FailedOperation(SafeErc20FailedOperation { token, }) })?; - Ok(U256::from_be_slice(&allowance)) + Ok(U256::from_be_slice(&result)) } /// Returns true if a slice of bytes is an ABI encoded `true` value. diff --git a/contracts/src/token/erc721/extensions/consecutive.rs b/contracts/src/token/erc721/extensions/consecutive.rs index 3ed58a3f7..678ec05ed 100644 --- a/contracts/src/token/erc721/extensions/consecutive.rs +++ b/contracts/src/token/erc721/extensions/consecutive.rs @@ -144,6 +144,9 @@ pub enum Error { ForbiddenBatchBurn(ERC721ForbiddenBatchBurn), } +/// 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 Erc721Consecutive {} // ************** ERC-721 External ************** diff --git a/contracts/src/utils/mod.rs b/contracts/src/utils/mod.rs index b8f56cef7..d36001aab 100644 --- a/contracts/src/utils/mod.rs +++ b/contracts/src/utils/mod.rs @@ -5,7 +5,9 @@ pub mod math; pub mod metadata; pub mod nonces; pub mod pausable; +pub mod reentrant_call_handler; pub mod structs; pub use metadata::Metadata; pub use pausable::Pausable; +pub use reentrant_call_handler::ReentrantCallHandler; diff --git a/contracts/src/utils/reentrant_call_handler.rs b/contracts/src/utils/reentrant_call_handler.rs new file mode 100644 index 000000000..f32de8e34 --- /dev/null +++ b/contracts/src/utils/reentrant_call_handler.rs @@ -0,0 +1,96 @@ +//! This module provides functionality for handling contract calls with +//! safeguards for reentrancy. +//! +//! The [`ReentrantCallHandler`] trait allows performing raw contract calls +//! while managing reentrancy issues, particularly when the `reentrant` feature +//! is enabled. This ensures that storage aliasing does not occur during +//! reentrant calls by flushing storage caches before the call is made. +//! +//! The behavior of the trait method +//! [`ReentrantCallHandler::call_with_reentrant_handling`] varies depending on +//! the presence of the `reentrant` feature, providing either a safe or default +//! (unsafe) raw call mechanism. The module also interacts with raw calls and +//! storage management methods like [ReentrantCallHandler::flush_storage_cache] +//! to ensure that data integrity is maintained when making contract calls. +//! +//! For more details on the inner workings of raw calls and storage cache +//! management, see the documentation for [RawCall::call] and +//! [ReentrantCallHandler::flush_storage_cache]. +//! +//! [RawCall::call]: https://docs.rs/stylus-sdk/0.6.0/stylus_sdk/call/struct.RawCall.html#method.call +//! [ReentrantCallHandler::flush_storage_cache]: https://docs.rs/stylus-sdk/0.6.0/stylus_sdk/call/struct.RawCall.html#method.flush_storage_cache + +use alloy_primitives::Address; +use stylus_sdk::{call::RawCall, ArbResult}; + +/// A trait for handling calls that may require special handling for reentrancy. +/// +/// This trait defines the method +/// [`ReentrantCallHandler::call_with_reentrant_handling`], which is intended to +/// perform a contract call with safeguards against reentrancy issues. The +/// behavior of the method can vary depending on whether the `reentrant` feature +/// is enabled: +/// +/// - When the `reentrant` feature is enabled, the +/// [`ReentrantCallHandler::call_with_reentrant_handling`] method will ensure +/// that the storage cache is flushed before making the contract call to avoid +/// potential issues with aliasing storage during a reentrant call. This is +/// considered unsafe due to potential aliasing of storage in the middle of a +/// storage reference's lifetime. See the +/// [ReentrantCallHandler::flush_storage_cache] method for more details on +/// handling storage caches. +/// - When the `reentrant` feature is not enabled, the method simply makes the +/// call without any additional safeguards. +/// +/// For more information on the safety of raw contract calls and storage +/// management, see: +/// - [RawCall::call] +/// - [ReentrantCallHandler::flush_storage_cache] +/// +/// [RawCall::call]: https://docs.rs/stylus-sdk/0.6.0/stylus_sdk/call/struct.RawCall.html#method.call +/// [ReentrantCallHandler::flush_storage_cache]: https://docs.rs/stylus-sdk/0.6.0/stylus_sdk/call/struct.RawCall.html#method.flush_storage_cache +pub trait ReentrantCallHandler { + /// Executes a contract call with reentrancy safeguards, returning the call + /// result. + /// + /// This method performs a raw call to another contract using the provided + /// `token` and `call_data`. The method behavior changes based on the + /// `reentrant` feature: + /// + /// - With the `reentrant` feature enabled, it flushes any cached storage + /// values before the call to prevent storage aliasing. + /// - Without the `reentrant` feature, it makes the call directly without + /// additional safeguards. + /// + /// # Arguments + /// + /// * `token` - The address of the contract being called. + /// * `call_data` - The encoded data for the contract call. + /// + /// # Errors + /// + /// * Returns [`stylus_sdk::ArbResult`] indicating the success or failure of + /// the call. + fn call_with_reentrant_handling( + self, + token: Address, + call_data: &[u8], + ) -> ArbResult; +} + +impl ReentrantCallHandler for RawCall { + fn call_with_reentrant_handling( + self, + token: Address, + call_data: &[u8], + ) -> ArbResult { + #[cfg(feature = "reentrant")] + unsafe { + self.flush_storage_cache().call(token, call_data) + } + #[cfg(not(feature = "reentrant"))] + { + self.call(token, call_data) + } + } +} diff --git a/docs/modules/ROOT/pages/erc20-flash-mint.adoc b/docs/modules/ROOT/pages/erc20-flash-mint.adoc new file mode 100644 index 000000000..b49a0cbbe --- /dev/null +++ b/docs/modules/ROOT/pages/erc20-flash-mint.adoc @@ -0,0 +1,73 @@ += ERC-20 Flash Mint + +Extension of xref:erc20.adoc[ERC-20] that provides flash loan support at the token level. + +[[usage]] +== Usage + +In order to make https://docs.rs/openzeppelin-stylus/0.2.0-alpha.3/openzeppelin_stylus/token/erc20/extensions/flash_mint/index.html[`ERC-20 Flash Mint`] methods “external” so that other contracts can call them, you need to add the following code to your contract: + +[source,rust] +---- +use openzeppelin_stylus::token::erc20::{ + extensions::{Erc20FlashMint, IErc3156FlashLender}, + Erc20, +}; + +#[entrypoint] +#[storage] +struct Erc20FlashMintExample { + #[borrow] + erc20: Erc20, + #[borrow] + flash_mint: Erc20FlashMint, +} + +#[public] +#[inherit(Erc20)] +impl Erc20FlashMintExample { + fn max_flash_loan(&self, token: Address) -> U256 { + self.flash_mint.max_flash_loan(token, &self.erc20) + } + + fn flash_fee(&self, token: Address, value: U256) -> Result> { + Ok(self.flash_mint.flash_fee(token, value)?) + } + + fn flash_loan( + &mut self, + receiver: Address, + token: Address, + value: U256, + data: Bytes, + ) -> Result> { + Ok(self.flash_mint.flash_loan( + receiver, + token, + value, + data, + &mut self.erc20, + )?) + } +} +---- + +Additionally, if you wish to set a flash loan fee and/or a fee receiver, you need to ensure proper initialization during xref:deploy.adoc[contract deployment]. +Make sure to include the following code in your Solidity Constructor: + +[source,solidity] +---- +contract Erc20FlashMintExample { + // ... + + uint256 private _flashFeeAmount; + address private _flashFeeReceiverAddress; + + constructor(address flashFeeReceiverAddress_, uint256 flashFeeAmount_) { + // ... + _flashFeeReceiverAddress = flashFeeReceiverAddress_; + _flashFeeAmount = flashFeeAmount_; + // ... + } +} +---- diff --git a/docs/modules/ROOT/pages/erc20.adoc b/docs/modules/ROOT/pages/erc20.adoc index 6294fefd5..869d8ddcc 100644 --- a/docs/modules/ROOT/pages/erc20.adoc +++ b/docs/modules/ROOT/pages/erc20.adoc @@ -82,3 +82,5 @@ Additionally, there are multiple custom extensions, including: * xref:erc20-pausable.adoc[ERC-20 Pausable]: ability to pause token transfers. * xref:erc20-permit.adoc[ERC-20 Permit]: gasless approval of tokens (standardized as https://eips.ethereum.org/EIPS/eip-2612[`EIP-2612`]). + + * 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/examples/erc20-flash-mint/Cargo.toml b/examples/erc20-flash-mint/Cargo.toml new file mode 100644 index 000000000..8376e7853 --- /dev/null +++ b/examples/erc20-flash-mint/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "erc20-flash-mint-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[dependencies] +openzeppelin-stylus = { workspace = true, features = ["reentrant"] } +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/erc20-flash-mint/src/constructor.sol b/examples/erc20-flash-mint/src/constructor.sol new file mode 100644 index 000000000..44ddb3953 --- /dev/null +++ b/examples/erc20-flash-mint/src/constructor.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +contract Erc20FlashMintExample { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + uint256 private _flashFeeAmount; + address private _flashFeeReceiverAddress; + + constructor(address flashFeeReceiverAddress_, uint256 flashFeeAmount_) { + _flashFeeReceiverAddress = flashFeeReceiverAddress_; + _flashFeeAmount = flashFeeAmount_; + } +} diff --git a/examples/erc20-flash-mint/src/lib.rs b/examples/erc20-flash-mint/src/lib.rs new file mode 100644 index 000000000..e4911abab --- /dev/null +++ b/examples/erc20-flash-mint/src/lib.rs @@ -0,0 +1,55 @@ +#![cfg_attr(not(test), no_main)] +extern crate alloc; + +use alloc::vec::Vec; + +use alloy_primitives::{Address, U256}; +use openzeppelin_stylus::token::erc20::{ + extensions::{Erc20FlashMint, IErc3156FlashLender}, + Erc20, +}; +use stylus_sdk::{ + abi::Bytes, + prelude::{entrypoint, public, storage}, +}; + +#[entrypoint] +#[storage] +struct Erc20FlashMintExample { + #[borrow] + erc20: Erc20, + #[borrow] + flash_mint: Erc20FlashMint, +} + +#[public] +#[inherit(Erc20)] +impl Erc20FlashMintExample { + fn max_flash_loan(&self, token: Address) -> U256 { + self.flash_mint.max_flash_loan(token, &self.erc20) + } + + fn flash_fee(&self, token: Address, value: U256) -> Result> { + Ok(self.flash_mint.flash_fee(token, value)?) + } + + fn flash_loan( + &mut self, + receiver: Address, + token: Address, + value: U256, + data: Bytes, + ) -> Result> { + Ok(self.flash_mint.flash_loan( + receiver, + token, + value, + data, + &mut self.erc20, + )?) + } + + fn mint(&mut self, to: Address, value: U256) -> Result<(), Vec> { + Ok(self.erc20._mint(to, value)?) + } +} diff --git a/examples/erc20-flash-mint/tests/abi/mod.rs b/examples/erc20-flash-mint/tests/abi/mod.rs new file mode 100644 index 000000000..3bb6385fa --- /dev/null +++ b/examples/erc20-flash-mint/tests/abi/mod.rs @@ -0,0 +1,36 @@ +#![allow(dead_code)] +use alloy::sol; + +sol!( + #[sol(rpc)] + contract Erc20FlashMint { + 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 mint(address account, uint256 amount) external; + + function maxFlashLoan(address token) external view returns (uint256 maxLoan); + #[derive(Debug)] + function flashFee(address token, uint256 amount) external view returns (uint256 fee); + function flashLoan(address receiver, address token, uint256 amount, bytes calldata data) external returns (bool); + + 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 ERC3156UnsupportedToken(address token); + error ERC3156ExceededMaxLoan(uint256 maxLoan); + error ERC3156InvalidReceiver(address receiver); + + #[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); + } +); diff --git a/examples/erc20-flash-mint/tests/erc20-flash-mint.rs b/examples/erc20-flash-mint/tests/erc20-flash-mint.rs new file mode 100644 index 000000000..988e045f5 --- /dev/null +++ b/examples/erc20-flash-mint/tests/erc20-flash-mint.rs @@ -0,0 +1,743 @@ +#![cfg(feature = "e2e")] + +use abi::Erc20FlashMint; +use alloy::{ + primitives::{address, uint, Address, U256}, + sol, +}; +use e2e::{ + receipt, send, watch, Account, EventExt, Panic, PanicCode, ReceiptExt, + Revert, +}; +use eyre::Result; +use mock::{borrower, borrower::ERC3156FlashBorrowerMock}; +use stylus_sdk::alloy_sol_types::SolCall; + +use crate::Erc20FlashMintExample::constructorCall; + +mod abi; +mod mock; + +sol!("src/constructor.sol"); + +const FEE_RECEIVER: Address = + address!("F4EaCDAbEf3c8f1EdE91b6f2A6840bc2E4DD3526"); +const FLASH_FEE_VALUE: U256 = uint!(100_U256); + +impl Default for constructorCall { + fn default() -> Self { + ctr(FEE_RECEIVER, FLASH_FEE_VALUE) + } +} + +fn ctr(fee_receiver: Address, fee_amount: U256) -> constructorCall { + Erc20FlashMintExample::constructorCall { + flashFeeReceiverAddress_: fee_receiver, + flashFeeAmount_: fee_amount, + } +} + +#[e2e::test] +async fn constructs(alice: Account) -> Result<()> { + let contract_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let contract = Erc20FlashMint::new(contract_addr, &alice.wallet); + + let max = contract.maxFlashLoan(contract_addr).call().await?.maxLoan; + let fee = contract.flashFee(contract_addr, U256::from(1)).call().await?.fee; + + assert_eq!(max, U256::MAX); + assert_eq!(fee, FLASH_FEE_VALUE); + + Ok(()) +} + +#[e2e::test] +async fn max_flash_loan(alice: Account) -> Result<()> { + let contract_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let contract = Erc20FlashMint::new(contract_addr, &alice.wallet); + + let alice_addr = alice.address(); + let mint_amount = uint!(1_000_000_U256); + let _ = watch!(contract.mint(alice_addr, mint_amount))?; + + let max_loan = contract.maxFlashLoan(contract_addr).call().await?.maxLoan; + assert_eq!(U256::MAX - mint_amount, max_loan); + + Ok(()) +} + +#[e2e::test] +async fn max_flash_loan_return_zero_if_no_more_tokens_to_mint( + alice: Account, +) -> Result<()> { + let contract_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let contract = Erc20FlashMint::new(contract_addr, &alice.wallet); + + let alice_addr = alice.address(); + let _ = watch!(contract.mint(alice_addr, U256::MAX))?; + + let max_loan = contract.maxFlashLoan(contract_addr).call().await?.maxLoan; + assert_eq!(U256::MIN, max_loan); + + Ok(()) +} + +#[e2e::test] +async fn max_flash_loan_returns_zero_on_invalid_address( + alice: Account, +) -> Result<()> { + let contract_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let contract = Erc20FlashMint::new(contract_addr, &alice.wallet); + + let alice_addr = alice.address(); + let mint_amount = uint!(1_000_000_U256); + let _ = watch!(contract.mint(alice_addr, mint_amount))?; + + // non-token address + let max_loan = contract.maxFlashLoan(alice_addr).call().await?.maxLoan; + assert_eq!(U256::MIN, max_loan); + + // works for zero address too + let max_loan = contract.maxFlashLoan(Address::ZERO).call().await?.maxLoan; + assert_eq!(U256::MIN, max_loan); + + Ok(()) +} + +// NOTE: this behavior is assumed for our implementation, but other +// implementations may have different behavior (e.g. return fee as a percentage +// of the passed amount). +#[e2e::test] +async fn flash_fee_returns_same_value_regardless_of_amount( + alice: Account, +) -> Result<()> { + let contract_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let contract = Erc20FlashMint::new(contract_addr, &alice.wallet); + + let amounts = &[U256::ZERO, U256::from(1), U256::from(1000), U256::MAX]; + for &amount in amounts { + let fee = contract.flashFee(contract_addr, amount).call().await?.fee; + assert_eq!(fee, FLASH_FEE_VALUE); + } + + Ok(()) +} + +#[e2e::test] +async fn flash_fee_reverts_on_unsupported_token(alice: Account) -> Result<()> { + let contract_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let contract = Erc20FlashMint::new(contract_addr, &alice.wallet); + + let unsupported_token = alice.address(); + + let err = contract + .flashFee(unsupported_token, U256::from(1)) + .call() + .await + .expect_err("should return `UnsupportedToken`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156UnsupportedToken { + token: unsupported_token + })); + + let err = contract + .flashFee(Address::ZERO, U256::from(1)) + .call() + .await + .expect_err("should return `UnsupportedToken`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156UnsupportedToken { + token: Address::ZERO + })); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_with_fee(alice: Account) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_constructor(ctr(Address::ZERO, FLASH_FEE_VALUE)) + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let _ = watch!(erc20.mint(borrower_addr, FLASH_FEE_VALUE))?; + let loan_amount = uint!(1_000_000_U256); + + let borrower_balance = erc20.balanceOf(borrower_addr).call().await?.balance; + let total_supply = erc20.totalSupply().call().await?.totalSupply; + + assert_eq!(FLASH_FEE_VALUE, borrower_balance); + assert_eq!(FLASH_FEE_VALUE, total_supply); + + let receipt = receipt!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into() + ))?; + + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: Address::ZERO, + to: borrower_addr, + value: loan_amount, + })); + assert!(receipt.emits(ERC3156FlashBorrowerMock::BalanceOf { + token: erc20_addr, + account: borrower_addr, + value: loan_amount + FLASH_FEE_VALUE, + })); + assert!(receipt.emits(ERC3156FlashBorrowerMock::TotalSupply { + token: erc20_addr, + value: loan_amount + FLASH_FEE_VALUE, + })); + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: borrower_addr, + to: Address::ZERO, + value: loan_amount + FLASH_FEE_VALUE, + })); + + let borrower_balance = erc20.balanceOf(borrower_addr).call().await?.balance; + let total_supply = erc20.totalSupply().call().await?.totalSupply; + + assert_eq!(U256::ZERO, borrower_balance); + assert_eq!(U256::ZERO, total_supply); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_with_fee_receiver(alice: Account) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_constructor(ctr(FEE_RECEIVER, U256::ZERO)) + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let loan_amount = uint!(1_000_000_U256); + + let borrower_balance = erc20.balanceOf(borrower_addr).call().await?.balance; + let fee_receiver_balance = + erc20.balanceOf(FEE_RECEIVER).call().await?.balance; + let total_supply = erc20.totalSupply().call().await?.totalSupply; + + assert_eq!(U256::ZERO, borrower_balance); + assert_eq!(U256::ZERO, fee_receiver_balance); + assert_eq!(U256::ZERO, total_supply); + + let receipt = receipt!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into() + ))?; + + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: Address::ZERO, + to: borrower_addr, + value: loan_amount, + })); + assert!(receipt.emits(ERC3156FlashBorrowerMock::BalanceOf { + token: erc20_addr, + account: borrower_addr, + value: loan_amount, + })); + assert!(receipt.emits(ERC3156FlashBorrowerMock::TotalSupply { + token: erc20_addr, + value: loan_amount, + })); + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: borrower_addr, + to: Address::ZERO, + value: loan_amount, + })); + + let borrower_balance = erc20.balanceOf(borrower_addr).call().await?.balance; + let fee_receiver_balance = + erc20.balanceOf(FEE_RECEIVER).call().await?.balance; + let total_supply = erc20.totalSupply().call().await?.totalSupply; + + assert_eq!(U256::ZERO, borrower_balance); + assert_eq!(U256::ZERO, fee_receiver_balance); + assert_eq!(U256::ZERO, total_supply); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_with_fee_and_fee_receiver(alice: Account) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let _ = watch!(erc20.mint(borrower_addr, FLASH_FEE_VALUE))?; + let loan_amount = uint!(1_000_000_U256); + + let borrower_balance = erc20.balanceOf(borrower_addr).call().await?.balance; + let fee_receiver_balance = + erc20.balanceOf(FEE_RECEIVER).call().await?.balance; + let total_supply = erc20.totalSupply().call().await?.totalSupply; + + assert_eq!(FLASH_FEE_VALUE, borrower_balance); + assert_eq!(U256::ZERO, fee_receiver_balance); + assert_eq!(FLASH_FEE_VALUE, total_supply); + + let receipt = receipt!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into() + ))?; + + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: Address::ZERO, + to: borrower_addr, + value: loan_amount, + })); + assert!(receipt.emits(ERC3156FlashBorrowerMock::BalanceOf { + token: erc20_addr, + account: borrower_addr, + value: loan_amount + FLASH_FEE_VALUE, + })); + assert!(receipt.emits(ERC3156FlashBorrowerMock::TotalSupply { + token: erc20_addr, + value: loan_amount + FLASH_FEE_VALUE, + })); + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: borrower_addr, + to: Address::ZERO, + value: loan_amount, + })); + assert!(receipt.emits(Erc20FlashMint::Transfer { + from: borrower_addr, + to: FEE_RECEIVER, + value: FLASH_FEE_VALUE, + })); + + let borrower_balance = erc20.balanceOf(borrower_addr).call().await?.balance; + let fee_receiver_balance = + erc20.balanceOf(FEE_RECEIVER).call().await?.balance; + let total_supply = erc20.totalSupply().call().await?.totalSupply; + + assert_eq!(U256::ZERO, borrower_balance); + assert_eq!(FLASH_FEE_VALUE, fee_receiver_balance); + assert_eq!(FLASH_FEE_VALUE, total_supply); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_loan_amount_greater_than_max_loan( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let max_loan = U256::from(1); + let loan_amount = U256::from(2); + + let _ = watch!(erc20.mint(borrower_addr, U256::MAX - max_loan))?; + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into(), + )) + .expect_err("should revert with `ERC3156ExceededMaxLoan`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156ExceededMaxLoan { + maxLoan: max_loan + })); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_with_exceeded_max_with_unsupported_token( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let invalid_token = alice.address(); + let loan_amount = U256::from(1); + + let err = send!(erc20.flashLoan( + borrower_addr, + invalid_token, + loan_amount, + vec![].into() + )) + .expect_err("should revert with `ERC3156ExceededMaxLoan`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156ExceededMaxLoan { + maxLoan: U256::ZERO + })); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_with_unsupported_token_with_zero_loan_amount_and_unsupported_token( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let invalid_token = alice.address(); + let loan_amount = U256::ZERO; + + let err = send!(erc20.flashLoan( + borrower_addr, + invalid_token, + loan_amount, + vec![].into() + )) + .expect_err("should revert with `ERC3156UnsupportedToken`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156UnsupportedToken { + token: invalid_token + })); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_invalid_receiver( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let _ = watch!(erc20.mint(borrower_addr, FLASH_FEE_VALUE))?; + let loan_amount = U256::from(1); + + let invalid_receivers = &[alice.address(), Address::ZERO]; + + for &invalid_receiver in invalid_receivers { + let err = send!(erc20.flashLoan( + invalid_receiver, + erc20_addr, + loan_amount, + vec![].into() + )) + .expect_err("should revert with `ERC3156InvalidReceiver`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156InvalidReceiver { + receiver: invalid_receiver + }),); + } + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_receiver_callback_reverts( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let _ = watch!(erc20.mint(borrower_addr, FLASH_FEE_VALUE))?; + let loan_amount = U256::from(1); + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![1, 2].into() + )) + .expect_err("should revert with `ERC3156InvalidReceiver`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156InvalidReceiver { + receiver: borrower_addr + }),); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_receiver_returns_invalid_callback_value( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, false, true).await?; + let _ = watch!(erc20.mint(borrower_addr, FLASH_FEE_VALUE))?; + let loan_amount = U256::from(1); + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into() + )) + .expect_err("should revert with `ERC3156InvalidReceiver`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC3156InvalidReceiver { + receiver: borrower_addr + }),); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_receiver_doesnt_approve_allowance( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, false).await?; + let _ = watch!(erc20.mint(borrower_addr, FLASH_FEE_VALUE))?; + let loan_amount = U256::from(1); + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into() + )) + .expect_err("should revert with `ERC20InsufficientAllowance`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC20InsufficientAllowance { + spender: erc20_addr, + allowance: U256::ZERO, + needed: loan_amount + FLASH_FEE_VALUE + }),); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_allowance_overflows( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, false).await?; + let loan_amount = U256::MAX; + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into() + )) + .expect_err("should panic due to allowance overflow"); + + assert!(err.panicked_with(PanicCode::ArithmeticOverflow)); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_receiver_doesnt_have_enough_tokens( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_default_constructor::() + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let loan_amount = U256::from(1); + + // test when not enough to cover fees + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into(), + )) + .expect_err("should revert with `ERC20InsufficientBalance`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC20InsufficientBalance { + sender: borrower_addr, + balance: U256::ZERO, + needed: FLASH_FEE_VALUE + })); + + // test when not enough to return the loaned tokens + let call = Erc20FlashMint::transferCall { + recipient: alice.address(), + amount: loan_amount, + }; + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + call.abi_encode().into(), + )) + .expect_err("should revert with `ERC20InsufficientBalance`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC20InsufficientBalance { + sender: borrower_addr, + balance: U256::ZERO, + needed: loan_amount, + })); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_receiver_doesnt_have_enough_tokens_and_fee_is_zero( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_constructor(ctr(FEE_RECEIVER, U256::ZERO)) + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let loan_amount = U256::from(1); + + let call = Erc20FlashMint::transferCall { + recipient: alice.address(), + amount: loan_amount, + }; + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + call.abi_encode().into(), + )) + .expect_err("should revert with `ERC20InsufficientBalance`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC20InsufficientBalance { + sender: borrower_addr, + balance: U256::ZERO, + needed: loan_amount, + })); + + Ok(()) +} + +#[e2e::test] +async fn flash_loan_reverts_when_receiver_doesnt_have_enough_tokens_and_fee_receiver_is_zero( + alice: Account, +) -> Result<()> { + let erc20_addr = alice + .as_deployer() + .with_constructor(ctr(Address::ZERO, FLASH_FEE_VALUE)) + .deploy() + .await? + .address()?; + let erc20 = Erc20FlashMint::new(erc20_addr, &alice.wallet); + + let borrower_addr = borrower::deploy(&alice.wallet, true, true).await?; + let loan_amount = U256::from(1); + + let err = send!(erc20.flashLoan( + borrower_addr, + erc20_addr, + loan_amount, + vec![].into(), + )) + .expect_err("should revert with `ERC20InsufficientBalance`"); + + assert!(err.reverted_with(Erc20FlashMint::ERC20InsufficientBalance { + sender: borrower_addr, + balance: loan_amount, + needed: loan_amount + FLASH_FEE_VALUE + })); + + Ok(()) +} diff --git a/examples/erc20-flash-mint/tests/mock/borrower.rs b/examples/erc20-flash-mint/tests/mock/borrower.rs new file mode 100644 index 000000000..2baa34339 --- /dev/null +++ b/examples/erc20-flash-mint/tests/mock/borrower.rs @@ -0,0 +1,67 @@ +#![allow(dead_code)] +#![cfg(feature = "e2e")] +use alloy::{primitives::Address, sol}; +use e2e::Wallet; + +sol! { + #[allow(missing_docs)] + // Built with Remix IDE; solc v0.8.24+commit.e11b9ed9; optimization: 200 + #[sol(rpc, bytecode="60c060405234801561000f575f80fd5b5060405161069f38038061069f83398101604081905261002e91610051565b1515608052151560a052610082565b8051801515811461004c575f80fd5b919050565b5f8060408385031215610062575f80fd5b61006b8361003d565b91506100796020840161003d565b90509250929050565b60805160a0516105fc6100a35f395f6102f901525f61024b01526105fc5ff3fe608060405234801561000f575f80fd5b5060043610610029575f3560e01c806323e30c8b1461002d575b5f80fd5b61004061003b3660046104a7565b610052565b60405190815260200160405180910390f35b5f336001600160a01b038716146100a85760405162461bcd60e51b8152602060048201526015602482015274496e76616c696420746f6b656e206164647265737360581b60448201526064015b60405180910390fd5b6040516370a0823160e01b815230600482018190527f6ff2acfcb07917b1e80e53f0fe390b467b1151d15b38730a6e08397799c05a8b918891906001600160a01b038316906370a0823190602401602060405180830381865afa158015610111573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906101359190610545565b604080516001600160a01b0394851681529390921660208401529082015260600160405180910390a17f7249fd4c03cce09b30a13d77804b198e2647c0ccd59eadf4de4e7c16099badc586876001600160a01b03166318160ddd6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156101bc573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906101e09190610545565b604080516001600160a01b03909316835260208301919091520160405180910390a18115610249576102478684848080601f0160208091040260200160405190810160405280939291908181526020018383808284375f9201919091525061034f92505050565b505b7f0000000000000000000000000000000000000000000000000000000000000000156102f7576001600160a01b03861663095ea7b387610289878961055c565b6040516001600160e01b031960e085901b1681526001600160a01b03909216600483015260248201526044016020604051808303815f875af11580156102d1573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906102f5919061057b565b505b7f0000000000000000000000000000000000000000000000000000000000000000610322575f610344565b7f439148f0bbc682ca079e46d6e2c2f0c1e3b820f1a291b069d8882abf8cf18dd95b979650505050505050565b606061035c83835f610365565b90505b92915050565b6060814710156103915760405163cf47918160e01b81524760048201526024810183905260440161009f565b5f80856001600160a01b031684866040516103ac919061059a565b5f6040518083038185875af1925050503d805f81146103e6576040519150601f19603f3d011682016040523d82523d5f602084013e6103eb565b606091505b50915091506103fb868383610407565b925050505b9392505050565b60608261041c5761041782610463565b610400565b815115801561043357506001600160a01b0384163b155b1561045c57604051639996b31560e01b81526001600160a01b038516600482015260240161009f565b5080610400565b8051156104735780518082602001fd5b60405163d6bda27560e01b815260040160405180910390fd5b80356001600160a01b03811681146104a2575f80fd5b919050565b5f805f805f8060a087890312156104bc575f80fd5b6104c58761048c565b95506104d36020880161048c565b94506040870135935060608701359250608087013567ffffffffffffffff808211156104fd575f80fd5b818901915089601f830112610510575f80fd5b81358181111561051e575f80fd5b8a602082850101111561052f575f80fd5b6020830194508093505050509295509295509295565b5f60208284031215610555575f80fd5b5051919050565b8082018082111561035f57634e487b7160e01b5f52601160045260245ffd5b5f6020828403121561058b575f80fd5b81518015158114610400575f80fd5b5f82515f5b818110156105b9576020818601810151858301520161059f565b505f92019182525091905056fea264697066735822122072bcfdbac0cd2f50d6eb0f6c882d0babb57a52e62164f6dd7962b0938dbc2ac364736f6c63430008180033")] + contract ERC3156FlashBorrowerMock is IERC3156FlashBorrower { + bytes32 internal constant _RETURN_VALUE = + keccak256("ERC3156FlashBorrower.onFlashLoan"); + + bool immutable _enableApprove; + bool immutable _validReturn; + + #[derive(Debug, PartialEq)] + event BalanceOf(address token, address account, uint256 value); + #[derive(Debug, PartialEq)] + event TotalSupply(address token, uint256 value); + + constructor(bool validReturn, bool enableApprove) { + _enableApprove = enableApprove; + _validReturn = validReturn; + } + + function onFlashLoan( + address /* initiator */, + address token, + uint256 amount, + uint256 fee, + bytes calldata data + ) public returns (bytes32) { + require(msg.sender == token, "Invalid token address"); + + emit BalanceOf( + token, + address(this), + IERC20(token).balanceOf(address(this)) + ); + + emit TotalSupply(token, IERC20(token).totalSupply()); + + if (data.length > 0) { + // WARNING: This code is for testing purposes only! Do not use in production. + Address.functionCall(token, data); + } + + if (_enableApprove) { + IERC20(token).approve(token, amount + fee); + } + + return _validReturn ? _RETURN_VALUE : bytes32(0); + } + } +} + +pub async fn deploy( + wallet: &Wallet, + enable_return: bool, + enable_approve: bool, +) -> eyre::Result
{ + let contract = + ERC3156FlashBorrowerMock::deploy(wallet, enable_return, enable_approve) + .await?; + Ok(*contract.address()) +} diff --git a/examples/erc20-flash-mint/tests/mock/mod.rs b/examples/erc20-flash-mint/tests/mock/mod.rs new file mode 100644 index 000000000..332f3c393 --- /dev/null +++ b/examples/erc20-flash-mint/tests/mock/mod.rs @@ -0,0 +1 @@ +pub mod borrower; diff --git a/examples/ownable-two-step/Cargo.toml b/examples/ownable-two-step/Cargo.toml index 172c38d8b..f9440b1dd 100644 --- a/examples/ownable-two-step/Cargo.toml +++ b/examples/ownable-two-step/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ownable-two-step" +name = "ownable-two-step-example" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/lib/e2e/src/lib.rs b/lib/e2e/src/lib.rs index 8c35d7e62..04fa871d7 100644 --- a/lib/e2e/src/lib.rs +++ b/lib/e2e/src/lib.rs @@ -18,7 +18,7 @@ pub use system::{fund_account, provider, Provider, Wallet}; /// This macro provides a shorthand for broadcasting the transaction to the /// network. /// -/// See: +/// See: /// /// # Examples /// @@ -43,7 +43,7 @@ macro_rules! send { /// This macro provides a shorthand for broadcasting the transaction /// to the network, and then waiting for the given number of confirmations. /// -/// See: +/// See: /// /// # Examples /// @@ -69,7 +69,7 @@ macro_rules! watch { /// to the network, waiting for the given number of confirmations, and then /// fetching the transaction receipt. /// -/// See: +/// See: /// /// # Examples ///