diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e1c312a..19f5f6885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `IErc1155Burnable` extension. #417 +- `VestingWallet` contract. #402 +- `Erc1155Burnable` extension. #417 ### Changed -- Use `function_selector!` to calculate transfer type selector in `Erc1155`. #417 +- Implement `MethodError` for `safe_erc20::Error`. #402 +- Use `function_selector!` to calculate transfer type selector in `Erc1155`. #417 ### Fixed @@ -33,14 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed `only_owner` from the public interface of `Ownable`. #352 -### Changed - -- - -### Fixed - -- - ## [0.1.1] - 2024-10-28 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 10b0e2b54..5043d0fc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4035,6 +4035,19 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vesting-wallet-example" +version = "0.2.0-alpha.1" +dependencies = [ + "alloy", + "alloy-primitives", + "e2e", + "eyre", + "openzeppelin-stylus", + "stylus-sdk", + "tokio", +] + [[package]] name = "wait-timeout" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 46b037075..cf26be1b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "examples/erc1155", "examples/merkle-proofs", "examples/ownable", + "examples/vesting-wallet", "examples/access-control", "examples/basic/token", "examples/basic/script", @@ -39,6 +40,7 @@ default-members = [ "examples/safe-erc20", "examples/merkle-proofs", "examples/ownable", + "examples/vesting-wallet", "examples/ownable-two-step", "examples/access-control", "examples/basic/token", diff --git a/GUIDELINES.md b/GUIDELINES.md index d55615ba9..1ba87d1e8 100644 --- a/GUIDELINES.md +++ b/GUIDELINES.md @@ -3,7 +3,7 @@ ## Setup 1. Install [Docker]. -1. Install the [Solidity Compiler] version `0.8.24` +1. Install the [Solidity Compiler] version `0.8.24`. (NOTE: it is important to use this exact version to avoid compatibility issues). 1. Install toolchain providing `cargo` using [rustup]. 1. Install the cargo stylus tool with `cargo install --force cargo-stylus`. diff --git a/benches/src/lib.rs b/benches/src/lib.rs index bf6189123..f6f39d9a2 100644 --- a/benches/src/lib.rs +++ b/benches/src/lib.rs @@ -20,6 +20,7 @@ pub mod erc721; pub mod merkle_proofs; pub mod ownable; pub mod report; +pub mod vesting_wallet; #[derive(Debug, Deserialize)] struct ArbOtherFields { @@ -32,6 +33,7 @@ struct ArbOtherFields { /// Cache options for the contract. /// `Bid(0)` will likely cache the contract on the nitro test node. +#[derive(Clone)] pub enum CacheOpt { None, Bid(u32), diff --git a/benches/src/vesting_wallet.rs b/benches/src/vesting_wallet.rs new file mode 100644 index 000000000..484d335cb --- /dev/null +++ b/benches/src/vesting_wallet.rs @@ -0,0 +1,132 @@ +use alloy::{ + network::{AnyNetwork, EthereumWallet}, + primitives::Address, + providers::ProviderBuilder, + sol, + sol_types::{SolCall, SolConstructor}, + uint, +}; +use alloy_primitives::U256; +use e2e::{receipt, Account}; + +use crate::{ + report::{ContractReport, FunctionReport}, + CacheOpt, +}; + +sol!( + #[sol(rpc)] + contract VestingWallet { + function owner() public view virtual returns (address owner); + function receiveEther() external payable virtual; + function start() external view returns (uint256 start); + function duration() external view returns (uint256 duration); + function end() external view returns (uint256 end); + function released() external view returns (uint256 released); + function released(address token) external view returns (uint256 released); + function releasable() external view returns (uint256 releasable); + function releasable(address token) external view returns (uint256 releasable); + function release() external; + function release(address token) external; + function vestedAmount(uint64 timestamp) external view returns (uint256 vestedAmount); + function vestedAmount(address token, uint64 timestamp) external view returns (uint256 vestedAmount); + } + + #[sol(rpc)] + contract Erc20 { + function mint(address account, uint256 amount) external; + } +); + +sol!("../examples/vesting-wallet/src/constructor.sol"); +sol!("../examples/erc20/src/constructor.sol"); + +const START_TIMESTAMP: u64 = 1000; +const DURATION_SECONDS: u64 = 1000; + +const TOKEN_NAME: &str = "Test Token"; +const TOKEN_SYMBOL: &str = "TTK"; +const CAP: U256 = uint!(1_000_000_U256); + +pub async fn bench() -> eyre::Result { + let reports = run_with(CacheOpt::None).await?; + let report = reports + .into_iter() + .try_fold(ContractReport::new("VestingWallet"), 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_wallet = ProviderBuilder::new() + .network::() + .with_recommended_fillers() + .wallet(EthereumWallet::from(alice.signer.clone())) + .on_http(alice.url().parse()?); + + let contract_addr = deploy(&alice, cache_opt.clone()).await?; + let erc20_addr = deploy_token(&alice, cache_opt).await?; + + let contract = VestingWallet::new(contract_addr, &alice_wallet); + let erc20 = Erc20::new(erc20_addr, &alice_wallet); + + let _ = receipt!(contract.receiveEther().value(uint!(1000_U256)))?; + let _ = receipt!(erc20.mint(contract_addr, uint!(1000_U256)))?; + + // IMPORTANT: Order matters! + use VestingWallet::*; + #[rustfmt::skip] + let receipts = vec![ + (receiveEtherCall::SIGNATURE, receipt!(contract.receiveEther())?), + (startCall::SIGNATURE, receipt!(contract.start())?), + (durationCall::SIGNATURE, receipt!(contract.duration())?), + (endCall::SIGNATURE, receipt!(contract.end())?), + (released_0Call::SIGNATURE, receipt!(contract.released_0())?), + (released_1Call::SIGNATURE, receipt!(contract.released_1(erc20_addr))?), + (releasable_0Call::SIGNATURE, receipt!(contract.releasable_0())?), + (releasable_1Call::SIGNATURE, receipt!(contract.releasable_1(erc20_addr))?), + (release_0Call::SIGNATURE, receipt!(contract.release_0())?), + (release_1Call::SIGNATURE, receipt!(contract.release_1(erc20_addr))?), + (vestedAmount_0Call::SIGNATURE, receipt!(contract.vestedAmount_0(START_TIMESTAMP))?), + (vestedAmount_1Call::SIGNATURE, receipt!(contract.vestedAmount_1(erc20_addr, START_TIMESTAMP))?), + ]; + + receipts + .into_iter() + .map(FunctionReport::new) + .collect::>>() +} + +async fn deploy( + account: &Account, + cache_opt: CacheOpt, +) -> eyre::Result
{ + let args = VestingWalletExample::constructorCall { + beneficiary: account.address(), + startTimestamp: START_TIMESTAMP, + durationSeconds: DURATION_SECONDS, + }; + let args = alloy::hex::encode(args.abi_encode()); + crate::deploy(account, "vesting-wallet", Some(args), cache_opt).await +} + +async fn deploy_token( + account: &Account, + cache_opt: CacheOpt, +) -> eyre::Result
{ + let args = Erc20Example::constructorCall { + name_: TOKEN_NAME.to_owned(), + symbol_: TOKEN_SYMBOL.to_owned(), + cap_: CAP, + }; + let args = alloy::hex::encode(args.abi_encode()); + crate::deploy(account, "erc20", Some(args), cache_opt).await +} diff --git a/contracts/src/finance/mod.rs b/contracts/src/finance/mod.rs new file mode 100644 index 000000000..f02a56459 --- /dev/null +++ b/contracts/src/finance/mod.rs @@ -0,0 +1,2 @@ +//! Primitives for financial systems. +pub mod vesting_wallet; diff --git a/contracts/src/finance/vesting_wallet.rs b/contracts/src/finance/vesting_wallet.rs new file mode 100644 index 000000000..24b0e2a89 --- /dev/null +++ b/contracts/src/finance/vesting_wallet.rs @@ -0,0 +1,611 @@ +//! A vesting wallet is an ownable contract that can receive native currency and +//! [`crate::token::erc20::Erc20`] tokens, and release these assets to the +//! wallet owner, also referred to as "beneficiary", according to a vesting +//! schedule. +//! +//! Any assets transferred to this contract will follow the vesting schedule as +//! if they were locked from the beginning. Consequently, if the vesting has +//! already started, any amount of tokens sent to this contract will (at least +//! partly) be immediately releasable. +//! +//! By setting the duration to 0, one can configure this contract to behave like +//! an asset timelock that hold tokens for a beneficiary until a specified time. +//! +//! NOTE: Since the wallet is [`Ownable`], and ownership +//! can be transferred, it is possible to sell unvested tokens. Preventing this +//! in a smart contract is difficult, considering that: 1) a beneficiary address +//! could be a counterfactually deployed contract, 2) there is likely to be a +//! migration path for EOAs to become contracts in the near future. +//! +//! NOTE: When using this contract with any token whose balance is adjusted +//! automatically (i.e. a rebase token), make sure to account the supply/balance +//! adjustment in the vesting schedule to ensure the vested amount is as +//! intended. + +use alloy_primitives::{Address, U256, U64}; +use alloy_sol_types::sol; +use openzeppelin_stylus_proc::interface_id; +use stylus_sdk::{ + block, + call::{self, call, Call}, + contract, evm, function_selector, + storage::TopLevelStorage, + stylus_proc::{public, sol_storage, SolidityError}, +}; + +use crate::{ + access::ownable::{self, IOwnable, Ownable}, + token::erc20::utils::safe_erc20::{self, ISafeErc20, SafeErc20}, +}; + +sol! { + /// Emitted when `amount` of Ether has been released. + /// + /// * `amount` - Total Ether released. + #[allow(missing_docs)] + event EtherReleased(uint256 amount); + + /// Emitted when `amount` of ERC-20 `token` has been released. + /// + /// * `token` - Address of the token being released. + /// * `amount` - Number of tokens released. + #[allow(missing_docs)] + event ERC20Released(address indexed token, uint256 amount); +} + +sol! { + /// Indicates an error related to the underlying Ether transfer. + #[derive(Debug)] + #[allow(missing_docs)] + error ReleaseEtherFailed(); + + /// The token address is not valid (eg. `Address::ZERO`). + /// + /// * `token` - Address of the token being released. + #[derive(Debug)] + #[allow(missing_docs)] + error InvalidToken(address token); +} + +/// An error that occurred in the [`VestingWallet`] contract. +#[derive(SolidityError, Debug)] +pub enum Error { + /// Error type from [`Ownable`] contract [`ownable::Error`]. + Ownable(ownable::Error), + /// Indicates an error related to the underlying Ether transfer. + ReleaseEtherFailed(call::Error), + /// Error type from [`SafeErc20`] contract [`safe_erc20::Error`]. + SafeErc20(safe_erc20::Error), + /// The token address is not valid. (eg. `Address::ZERO`). + InvalidToken(InvalidToken), +} + +pub use token::IErc20; +#[allow(missing_docs)] +mod token { + stylus_sdk::stylus_proc::sol_interface! { + /// Interface of the ERC-20 token. + interface IErc20 { + function balanceOf(address account) external view returns (uint256); + } + } +} + +sol_storage! { + /// State of the [`VestingWallet`] Contract. + pub struct VestingWallet { + /// [`Ownable`] contract. + Ownable ownable; + /// Amount of Ether already released. + uint256 _released; + /// Amount of ERC-20 tokens already released. + mapping(address => uint256) _erc20_released; + /// Start timestamp. + uint64 _start; + /// Vesting duration. + uint64 _duration; + /// [`SafeErc20`] contract. + SafeErc20 safe_erc20; + } +} + +/// 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 VestingWallet {} + +/// Required interface of a [`VestingWallet`] compliant contract. +#[interface_id] +pub trait IVestingWallet { + /// The error type associated to this trait implementation. + type Error: Into>; + + /// Returns the address of the current owner. + /// + /// Re-export of [`Ownable::owner`]. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + fn owner(&self) -> Address; + + /// Transfers ownership of the contract to a new account (`new_owner`). Can + /// only be called by the current owner. + /// + /// Re-export of [`Ownable::transfer_ownership`]. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `new_owner` - The next owner of this contract. + /// + /// # Errors + /// + /// If called by any account other than the owner, then the error + /// [`ownable::Error::UnauthorizedAccount`] is returned. + /// If `new_owner` is the `Address::ZERO`, then the error + /// [`ownable::Error::InvalidOwner`] is returned. + /// + /// # Events + /// + /// Emits an [`ownable::OwnershipTransferred`] event. + fn transfer_ownership( + &mut self, + new_owner: Address, + ) -> Result<(), Self::Error>; + + /// Leaves the contract without owner. It will not be possible to call + /// [`Ownable::only_owner`] functions. Can only be called by the current + /// owner. + /// + /// Re-export of [`Ownable::renounce_ownership`]. + /// + /// NOTE: Renouncing ownership will leave the contract without an owner, + /// thereby disabling any functionality that is only available to the owner. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// + /// # Errors + /// + /// If not called by the owner, then the error + /// [`ownable::Error::UnauthorizedAccount`] is returned. + /// + /// # Events + /// + /// Emits an [`ownable::OwnershipTransferred`] event. + fn renounce_ownership(&mut self) -> Result<(), Self::Error>; + + /// The contract should be able to receive Ether. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + fn receive_ether(&self); + + /// Getter for the start timestamp. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + fn start(&self) -> U256; + + /// Getter for the vesting duration. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + fn duration(&self) -> U256; + + /// Getter for the end timestamp. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + fn end(&self) -> U256; + + /// Amount of Ether already released. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + #[selector(name = "released")] + fn released_eth(&self) -> U256; + + /// Amount of token already released. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `token` - Address of the token being released. + #[selector(name = "released")] + fn released_erc20(&self, token: Address) -> U256; + + /// Getter for the amount of releasable Ether. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// + /// # Panics + /// + /// If total allocation exceeds `U256::MAX`. + /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. + #[selector(name = "releasable")] + fn releasable_eth(&self) -> U256; + + /// Getter for the amount of releasable `token` tokens. `token` should be + /// the address of an ERC-20 contract. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `token` - Address of the releasable token. + /// + /// # Errors + /// + /// If the `token` address is not a contract, then the error + /// [`Error::InvalidToken`] is returned. + /// + /// # Panics + /// + /// 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; + + /// Release the native tokens (Ether) that have already vested. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// + /// # Errors + /// + /// If Ether transfer fails, then the error [`Error::ReleaseEtherFailed`] is + /// returned. + /// + /// # Events + /// + /// Emits an [`EtherReleased`] event. + /// + /// # Panics + /// + /// If total allocation exceeds `U256::MAX`. + /// If scaled total allocation (mid calculation) exceeds `U256::MAX`. + #[selector(name = "release")] + fn release_eth(&mut self) -> Result<(), Self::Error>; + + /// Release the tokens that have already vested. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `token` - Address of the token being released. + /// + /// # Errors + /// + /// If the `token` address is not a contract, then the error + /// [`Error::InvalidToken`] is returned. + /// If the contract fails to execute the call, then the error + /// [`safe_erc20::Error::SafeErc20FailedOperation`] is returned. + /// + /// # Events + /// + /// Emits an [`ERC20Released`] event. + /// + /// # Panics + /// + /// If total allocation exceeds `U256::MAX`. + /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. + #[selector(name = "release")] + fn release_erc20(&mut self, token: Address) -> Result<(), Self::Error>; + + /// Calculates the amount of Ether that has already vested. + /// The Default implementation is a linear vesting curve. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `timestamp` - Point in time for which to check the vested amount. + /// + /// # Panics + /// + /// If total allocation exceeds `U256::MAX`. + /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. + #[selector(name = "vestedAmount")] + fn vested_amount_eth(&self, timestamp: u64) -> U256; + + /// Calculates the amount of tokens that has already vested. + /// The Default implementation is a linear vesting curve. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `token` - Address of the token being released. + /// * `timestamp` - Point in time for which to check the vested amount. + /// + /// # Errors + /// + /// If the `token` address is not a contract, then the error + /// [`Error::InvalidToken`] is returned. + /// + /// # Panics + /// + /// If total allocation exceeds `U256::MAX`. + /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. + #[selector(name = "vestedAmount")] + fn vested_amount_erc20( + &self, + token: Address, + timestamp: u64, + ) -> Result; +} + +#[public] +impl IVestingWallet for VestingWallet { + type Error = Error; + + fn owner(&self) -> Address { + self.ownable.owner() + } + + fn transfer_ownership( + &mut self, + new_owner: Address, + ) -> Result<(), Self::Error> { + Ok(self.ownable.transfer_ownership(new_owner)?) + } + + fn renounce_ownership(&mut self) -> Result<(), Self::Error> { + Ok(self.ownable.renounce_ownership()?) + } + + #[payable] + fn receive_ether(&self) {} + + fn start(&self) -> U256 { + U256::from(self._start.get()) + } + + fn duration(&self) -> U256 { + U256::from(self._duration.get()) + } + + fn end(&self) -> U256 { + // SAFETY: both `start` and `duration` are stored as u64, + // so they cannot exceed `U256::MAX` + self.start() + self.duration() + } + + #[selector(name = "released")] + fn released_eth(&self) -> U256 { + self._released.get() + } + + #[selector(name = "released")] + fn released_erc20(&self, token: Address) -> U256 { + self._erc20_released.get(token) + } + + #[selector(name = "releasable")] + fn releasable_eth(&self) -> U256 { + // SAFETY: total vested amount is by definition greater than or equal to + // the released amount. + self.vested_amount_eth(block::timestamp()) - self.released_eth() + } + + #[selector(name = "releasable")] + fn releasable_erc20(&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. + Ok(vested - self.released_erc20(token)) + } + + #[selector(name = "release")] + fn release_eth(&mut self) -> Result<(), Self::Error> { + let amount = self.releasable_eth(); + + let released = self + ._released + .get() + .checked_add(amount) + .expect("total released should not exceed `U256::MAX`"); + self._released.set(released); + + let owner = self.ownable.owner(); + + call(Call::new_in(self).value(amount), owner, &[])?; + + evm::log(EtherReleased { amount }); + + Ok(()) + } + + #[selector(name = "release")] + fn release_erc20(&mut self, token: Address) -> Result<(), Self::Error> { + let amount = self.releasable_erc20(token)?; + let owner = self.ownable.owner(); + + let released = self + ._erc20_released + .get(token) + .checked_add(amount) + .expect("total released should not exceed `U256::MAX`"); + self._erc20_released.setter(token).set(released); + + self.safe_erc20.safe_transfer(token, owner, amount)?; + + evm::log(ERC20Released { token, amount }); + + Ok(()) + } + + #[selector(name = "vestedAmount")] + fn vested_amount_eth(&self, timestamp: u64) -> U256 { + let total_allocation = contract::balance() + .checked_add(self.released_eth()) + .expect("total allocation should not exceed `U256::MAX`"); + + self.vesting_schedule(total_allocation, U64::from(timestamp)) + } + + #[selector(name = "vestedAmount")] + fn vested_amount_erc20( + &self, + token: Address, + timestamp: u64, + ) -> Result { + let erc20 = IErc20::new(token); + let balance = erc20 + .balance_of(Call::new(), contract::address()) + .map_err(|_| InvalidToken { token })?; + + let total_allocation = balance + .checked_add(self.released_erc20(token)) + .expect("total allocation should not exceed `U256::MAX`"); + + Ok(self.vesting_schedule(total_allocation, U64::from(timestamp))) + } +} + +impl VestingWallet { + /// Virtual implementation of the vesting formula. This returns the amount + /// vested, as a function of time, for an asset given its total + /// historical allocation. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `total_allocation` - Total vested amount. + /// * `timestamp` - Point in time for which to calculate the vested amount. + /// + /// # Panics + /// + /// If scaled, total allocation (mid calculation) exceeds `U256::MAX`. + fn vesting_schedule(&self, total_allocation: U256, timestamp: U64) -> U256 { + let timestamp = U256::from(timestamp); + + if timestamp < self.start() { + U256::ZERO + } else if timestamp >= self.end() { + total_allocation + } else { + // SAFETY: `timestamp` is guaranteed to be greater than + // `self.start()` as checked by earlier bounds. + let elapsed = timestamp - self.start(); + + let scaled_allocation = total_allocation + .checked_mul(elapsed) + .expect("scaled allocation exceeds `U256::MAX`"); + + // SAFETY: `self.duration()` is non-zero. If `self.duration()` were + // zero, then `end == start`, meaning that `timestamp >= self.end()` + // and the function would have returned earlier. + scaled_allocation / self.duration() + } + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use alloy_primitives::{address, uint, Address, U256, U64}; + use stylus_sdk::block; + + use super::{IVestingWallet, VestingWallet}; + + const TOKEN: Address = address!("A11CEacF9aa32246d767FCCD72e02d6bCbcC375d"); + const DURATION: u64 = 4 * 365 * 86400; // 4 years + + fn start() -> u64 { + block::timestamp() + 3600 // 1 hour + } + + fn init( + contract: &mut VestingWallet, + start: u64, + duration: u64, + ) -> (U64, U64) { + let start = U64::from(start); + let duration = U64::from(duration); + contract._start.set(start); + contract._duration.set(duration); + (start, duration) + } + + #[motsu::test] + fn reads_start(contract: VestingWallet) { + let (start, _) = init(contract, start(), 0); + assert_eq!(U256::from(start), contract.start()); + } + + #[motsu::test] + fn reads_duration(contract: VestingWallet) { + let (_, duration) = init(contract, 0, DURATION); + assert_eq!(U256::from(duration), contract.duration()); + } + + #[motsu::test] + fn reads_end(contract: VestingWallet) { + let (start, duration) = init(contract, start(), DURATION); + assert_eq!(U256::from(start + duration), contract.end()); + } + + #[motsu::test] + fn reads_max_end(contract: VestingWallet) { + init(contract, u64::MAX, u64::MAX); + assert_eq!(U256::from(U64::MAX) + U256::from(U64::MAX), contract.end()); + } + + #[motsu::test] + fn reads_released_eth(contract: VestingWallet) { + let one = uint!(1_U256); + contract._released.set(one); + assert_eq!(one, contract.released_eth()); + } + + #[motsu::test] + fn reads_released_erc20(contract: VestingWallet) { + let one = uint!(1_U256); + contract._erc20_released.setter(TOKEN).set(one); + assert_eq!(one, contract.released_erc20(TOKEN)); + } + + #[motsu::test] + fn gets_vesting_schedule(contract: VestingWallet) { + let (start, duration) = init(contract, start(), DURATION); + + let one = uint!(1_U256); + let two = uint!(2_U256); + + assert_eq!( + U256::ZERO, + contract.vesting_schedule(two, start - U64::from(1)) + ); + assert_eq!( + one, + contract.vesting_schedule(two, start + duration / U64::from(2)) + ); + assert_eq!(two, contract.vesting_schedule(two, start + duration)); + assert_eq!( + two, + contract.vesting_schedule(two, start + duration + U64::from(1)) + ); + } + + #[motsu::test] + fn gets_vesting_schedule_zero_duration(contract: VestingWallet) { + let (start, _) = init(contract, start(), 0); + + let two = uint!(2_U256); + + assert_eq!( + U256::ZERO, + contract.vesting_schedule(two, start - U64::from(1)) + ); + assert_eq!(two, contract.vesting_schedule(two, start)); + assert_eq!(two, contract.vesting_schedule(two, start + U64::from(1))); + } +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index a3a77988f..aa0055308 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -49,5 +49,6 @@ impl MyContract { } extern crate alloc; pub mod access; +pub mod finance; pub mod token; pub mod utils; diff --git a/contracts/src/token/erc20/utils/safe_erc20.rs b/contracts/src/token/erc20/utils/safe_erc20.rs index 1669a69cd..28115e227 100644 --- a/contracts/src/token/erc20/utils/safe_erc20.rs +++ b/contracts/src/token/erc20/utils/safe_erc20.rs @@ -10,7 +10,7 @@ use alloy_primitives::{Address, U256}; use alloy_sol_types::{sol, SolCall}; use stylus_sdk::{ - call::RawCall, + call::{MethodError, RawCall}, contract::address, evm::gas_left, function_selector, @@ -54,6 +54,12 @@ pub enum Error { SafeErc20FailedDecreaseAllowance(SafeErc20FailedDecreaseAllowance), } +impl MethodError for Error { + fn encode(self) -> alloc::vec::Vec { + self.into() + } +} + pub use token::*; #[allow(missing_docs)] mod token { @@ -77,7 +83,7 @@ sol_storage! { /// BorrowMut)`. Should be fixed in the future by the Stylus team. unsafe impl TopLevelStorage for SafeErc20 {} -/// Required interface of an [`SafeErc20`] utility contract. +/// Required interface of a [`SafeErc20`] utility contract. pub trait ISafeErc20 { /// The error type associated to this trait implementation. type Error: Into>; diff --git a/contracts/src/utils/nonces.rs b/contracts/src/utils/nonces.rs index 752565174..13678fcc3 100644 --- a/contracts/src/utils/nonces.rs +++ b/contracts/src/utils/nonces.rs @@ -52,7 +52,7 @@ impl Nonces { /// * `&mut self` - Write access to the contract's state. /// * `owner` - The address for which to consume the nonce. /// - /// /// # Panics + /// # Panics /// /// This function will panic if the nonce for the given `owner` has reached /// the maximum value representable by `U256`, causing the `checked_add` diff --git a/docs/modules/ROOT/pages/ERC1155.adoc b/docs/modules/ROOT/pages/ERC1155.adoc index 9c16d0395..168296cfb 100644 --- a/docs/modules/ROOT/pages/ERC1155.adoc +++ b/docs/modules/ROOT/pages/ERC1155.adoc @@ -7,4 +7,4 @@ ERC1155 is a novel token standard that aims to take the best from previous stand Additionally, there are multiple custom extensions, including: -* xref:erc1155-burnable.adoc[ERC-1155 Burnable]: Optional Burnable extension of the ERC-1155 standard. \ No newline at end of file +* xref:erc1155-burnable.adoc[ERC-1155 Burnable]: Optional Burnable extension of the ERC-1155 standard. diff --git a/examples/safe-erc20/tests/erc20_that_does_not_return.rs b/examples/safe-erc20/tests/erc20_that_does_not_return.rs index 077096211..9fbffe3f9 100644 --- a/examples/safe-erc20/tests/erc20_that_does_not_return.rs +++ b/examples/safe-erc20/tests/erc20_that_does_not_return.rs @@ -352,7 +352,7 @@ mod approvals { spender_addr, value )) - .expect_err("should not exceed U256::MAX"); + .expect_err("should exceed U256::MAX"); assert!(err.panicked_with(PanicCode::ArithmeticOverflow)); diff --git a/examples/vesting-wallet/Cargo.toml b/examples/vesting-wallet/Cargo.toml new file mode 100644 index 000000000..7f6cbf464 --- /dev/null +++ b/examples/vesting-wallet/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vesting-wallet-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/vesting-wallet/src/constructor.sol b/examples/vesting-wallet/src/constructor.sol new file mode 100644 index 000000000..c94740a77 --- /dev/null +++ b/examples/vesting-wallet/src/constructor.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +contract VestingWalletExample { + address private _owner; + + uint256 private _released; + mapping(address => uint256) private _erc20Released; + uint64 private _start; + uint64 private _duration; + + error OwnableInvalidOwner(address owner); + + constructor( + address beneficiary, + uint64 startTimestamp, + uint64 durationSeconds + ) payable { + if (beneficiary == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _owner = beneficiary; + + _start = startTimestamp; + _duration = durationSeconds; + } +} diff --git a/examples/vesting-wallet/src/lib.rs b/examples/vesting-wallet/src/lib.rs new file mode 100644 index 000000000..6e74ebe28 --- /dev/null +++ b/examples/vesting-wallet/src/lib.rs @@ -0,0 +1,17 @@ +#![cfg_attr(not(test), no_main)] +extern crate alloc; + +use openzeppelin_stylus::finance::vesting_wallet::VestingWallet; +use stylus_sdk::prelude::{entrypoint, public, sol_storage}; + +sol_storage! { + #[entrypoint] + struct VestingWalletExample { + #[borrow] + VestingWallet vesting_wallet; + } +} + +#[public] +#[inherit(VestingWallet)] +impl VestingWalletExample {} diff --git a/examples/vesting-wallet/tests/abi/mod.rs b/examples/vesting-wallet/tests/abi/mod.rs new file mode 100644 index 000000000..361eecfa7 --- /dev/null +++ b/examples/vesting-wallet/tests/abi/mod.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +use alloy::sol; + +sol!( + #[sol(rpc)] + contract VestingWallet { + function owner() public view virtual returns (address owner); + function receiveEther() external payable virtual; + function start() external view returns (uint256 start); + function duration() external view returns (uint256 duration); + function end() external view returns (uint256 end); + function released() external view returns (uint256 released); + function released(address token) external view returns (uint256 released); + function releasable() external view returns (uint256 releasable); + function releasable(address token) external view returns (uint256 releasable); + function release() external; + function release(address token) external; + function vestedAmount(uint64 timestamp) external view returns (uint256 vestedAmount); + function vestedAmount(address token, uint64 timestamp) external view returns (uint256 vestedAmount); + + error OwnableUnauthorizedAccount(address account); + error OwnableInvalidOwner(address owner); + error ReleaseEtherFailed(); + error SafeErc20FailedOperation(address token); + error InvalidToken(address token); + + #[derive(Debug, PartialEq)] + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + #[derive(Debug, PartialEq)] + event EtherReleased(uint256 amount); + #[derive(Debug, PartialEq)] + event ERC20Released(address indexed token, uint256 amount); + } +); diff --git a/examples/vesting-wallet/tests/mock/erc20.rs b/examples/vesting-wallet/tests/mock/erc20.rs new file mode 100644 index 000000000..eaca2660a --- /dev/null +++ b/examples/vesting-wallet/tests/mock/erc20.rs @@ -0,0 +1,28 @@ +#![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.21+commit.d9974bed + #[sol(rpc, bytecode="608060405234801562000010575f80fd5b506040518060400160405280600981526020017f45524332304d6f636b00000000000000000000000000000000000000000000008152506040518060400160405280600381526020017f4d544b000000000000000000000000000000000000000000000000000000000081525081600390816200008e91906200030d565b508060049081620000a091906200030d565b505050620003f1565b5f81519050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f60028204905060018216806200012557607f821691505b6020821081036200013b576200013a620000e0565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f600883026200019f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8262000162565b620001ab868362000162565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f620001f5620001ef620001e984620001c3565b620001cc565b620001c3565b9050919050565b5f819050919050565b6200021083620001d5565b620002286200021f82620001fc565b8484546200016e565b825550505050565b5f90565b6200023e62000230565b6200024b81848462000205565b505050565b5b818110156200027257620002665f8262000234565b60018101905062000251565b5050565b601f821115620002c1576200028b8162000141565b620002968462000153565b81016020851015620002a6578190505b620002be620002b58562000153565b83018262000250565b50505b505050565b5f82821c905092915050565b5f620002e35f1984600802620002c6565b1980831691505092915050565b5f620002fd8383620002d2565b9150826002028217905092915050565b6200031882620000a9565b67ffffffffffffffff811115620003345762000333620000b3565b5b6200034082546200010d565b6200034d82828562000276565b5f60209050601f83116001811462000383575f84156200036e578287015190505b6200037a8582620002f0565b865550620003e9565b601f198416620003938662000141565b5f5b82811015620003bc5784890151825560018201915060208501945060208101905062000395565b86831015620003dc5784890151620003d8601f891682620002d2565b8355505b6001600288020188555050505b505050505050565b610ec080620003ff5f395ff3fe608060405234801561000f575f80fd5b506004361061009c575f3560e01c806340c10f191161006457806340c10f191461015a57806370a082311461017657806395d89b41146101a6578063a9059cbb146101c4578063dd62ed3e146101f45761009c565b806306fdde03146100a0578063095ea7b3146100be57806318160ddd146100ee57806323b872dd1461010c578063313ce5671461013c575b5f80fd5b6100a8610224565b6040516100b59190610b39565b60405180910390f35b6100d860048036038101906100d39190610bea565b6102b4565b6040516100e59190610c42565b60405180910390f35b6100f66102d6565b6040516101039190610c6a565b60405180910390f35b61012660048036038101906101219190610c83565b6102df565b6040516101339190610c42565b60405180910390f35b61014461030d565b6040516101519190610cee565b60405180910390f35b610174600480360381019061016f9190610bea565b610315565b005b610190600480360381019061018b9190610d07565b610323565b60405161019d9190610c6a565b60405180910390f35b6101ae610334565b6040516101bb9190610b39565b60405180910390f35b6101de60048036038101906101d99190610bea565b6103c4565b6040516101eb9190610c42565b60405180910390f35b61020e60048036038101906102099190610d32565b6103e6565b60405161021b9190610c6a565b60405180910390f35b60606003805461023390610d9d565b80601f016020809104026020016040519081016040528092919081815260200182805461025f90610d9d565b80156102aa5780601f10610281576101008083540402835291602001916102aa565b820191905f5260205f20905b81548152906001019060200180831161028d57829003601f168201915b5050505050905090565b5f806102be610468565b90506102cb81858561046f565b600191505092915050565b5f600254905090565b5f806102e9610468565b90506102f6858285610481565b610301858585610513565b60019150509392505050565b5f6012905090565b61031f8282610603565b5050565b5f61032d82610682565b9050919050565b60606004805461034390610d9d565b80601f016020809104026020016040519081016040528092919081815260200182805461036f90610d9d565b80156103ba5780601f10610391576101008083540402835291602001916103ba565b820191905f5260205f20905b81548152906001019060200180831161039d57829003601f168201915b5050505050905090565b5f806103ce610468565b90506103db818585610513565b600191505092915050565b5f60015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2054905092915050565b5f33905090565b61047c83838360016106c7565b505050565b5f61048c84846103e6565b90507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff811461050d57818110156104fe578281836040517ffb8f41b20000000000000000000000000000000000000000000000000000000081526004016104f593929190610ddc565b60405180910390fd5b61050c84848484035f6106c7565b5b50505050565b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610583575f6040517f96c6fd1e00000000000000000000000000000000000000000000000000000000815260040161057a9190610e11565b60405180910390fd5b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036105f3575f6040517fec442f050000000000000000000000000000000000000000000000000000000081526004016105ea9190610e11565b60405180910390fd5b6105fe838383610896565b505050565b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610673575f6040517fec442f0500000000000000000000000000000000000000000000000000000000815260040161066a9190610e11565b60405180910390fd5b61067e5f8383610896565b5050565b5f805f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20549050919050565b5f73ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff1603610737575f6040517fe602df0500000000000000000000000000000000000000000000000000000000815260040161072e9190610e11565b60405180910390fd5b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16036107a7575f6040517f94280d6200000000000000000000000000000000000000000000000000000000815260040161079e9190610e11565b60405180910390fd5b8160015f8673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20819055508015610890578273ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925846040516108879190610c6a565b60405180910390a35b50505050565b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16036108e6578060025f8282546108da9190610e57565b925050819055506109b4565b5f805f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205490508181101561096f578381836040517fe450d38c00000000000000000000000000000000000000000000000000000000815260040161096693929190610ddc565b60405180910390fd5b8181035f808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2081905550505b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036109fb578060025f8282540392505081905550610a45565b805f808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f82825401925050819055505b8173ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef83604051610aa29190610c6a565b60405180910390a3505050565b5f81519050919050565b5f82825260208201905092915050565b5f5b83811015610ae6578082015181840152602081019050610acb565b5f8484015250505050565b5f601f19601f8301169050919050565b5f610b0b82610aaf565b610b158185610ab9565b9350610b25818560208601610ac9565b610b2e81610af1565b840191505092915050565b5f6020820190508181035f830152610b518184610b01565b905092915050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610b8682610b5d565b9050919050565b610b9681610b7c565b8114610ba0575f80fd5b50565b5f81359050610bb181610b8d565b92915050565b5f819050919050565b610bc981610bb7565b8114610bd3575f80fd5b50565b5f81359050610be481610bc0565b92915050565b5f8060408385031215610c0057610bff610b59565b5b5f610c0d85828601610ba3565b9250506020610c1e85828601610bd6565b9150509250929050565b5f8115159050919050565b610c3c81610c28565b82525050565b5f602082019050610c555f830184610c33565b92915050565b610c6481610bb7565b82525050565b5f602082019050610c7d5f830184610c5b565b92915050565b5f805f60608486031215610c9a57610c99610b59565b5b5f610ca786828701610ba3565b9350506020610cb886828701610ba3565b9250506040610cc986828701610bd6565b9150509250925092565b5f60ff82169050919050565b610ce881610cd3565b82525050565b5f602082019050610d015f830184610cdf565b92915050565b5f60208284031215610d1c57610d1b610b59565b5b5f610d2984828501610ba3565b91505092915050565b5f8060408385031215610d4857610d47610b59565b5b5f610d5585828601610ba3565b9250506020610d6685828601610ba3565b9150509250929050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f6002820490506001821680610db457607f821691505b602082108103610dc757610dc6610d70565b5b50919050565b610dd681610b7c565b82525050565b5f606082019050610def5f830186610dcd565b610dfc6020830185610c5b565b610e096040830184610c5b565b949350505050565b5f602082019050610e245f830184610dcd565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610e6182610bb7565b9150610e6c83610bb7565b9250828201905080821115610e8457610e83610e2a565b5b9291505056fea2646970667358221220aae0e1f0f9317957e6b898e81a54f655e91a33a9848dbdd292ef970a0904968264736f6c63430008150033")] + // SPDX-License-Identifier: MIT + contract ERC20Mock is ERC20 { + constructor() ERC20("ERC20Mock", "MTK") {} + + function balanceOf(address account) public override view returns (uint256 balance) { + return super.balanceOf(account); + } + + function mint(address account, uint256 value) public { + super._mint(account, value); + } + } +} + +pub async fn deploy(wallet: &Wallet) -> eyre::Result
{ + // Deploy the contract. + let contract = ERC20Mock::deploy(wallet).await?; + Ok(*contract.address()) +} diff --git a/examples/vesting-wallet/tests/mock/erc20_return_false.rs b/examples/vesting-wallet/tests/mock/erc20_return_false.rs new file mode 100644 index 000000000..809c80626 --- /dev/null +++ b/examples/vesting-wallet/tests/mock/erc20_return_false.rs @@ -0,0 +1,44 @@ +#![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.21+commit.d9974bed + #[sol(rpc, bytecode="608060405234801562000010575f80fd5b506040518060400160405280601481526020017f455243323052657475726e46616c73654d6f636b0000000000000000000000008152506040518060400160405280600381526020017f52464d000000000000000000000000000000000000000000000000000000000081525081600390816200008e91906200030d565b508060049081620000a091906200030d565b505050620003f1565b5f81519050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f60028204905060018216806200012557607f821691505b6020821081036200013b576200013a620000e0565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f600883026200019f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8262000162565b620001ab868362000162565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f620001f5620001ef620001e984620001c3565b620001cc565b620001c3565b9050919050565b5f819050919050565b6200021083620001d5565b620002286200021f82620001fc565b8484546200016e565b825550505050565b5f90565b6200023e62000230565b6200024b81848462000205565b505050565b5b818110156200027257620002665f8262000234565b60018101905062000251565b5050565b601f821115620002c1576200028b8162000141565b620002968462000153565b81016020851015620002a6578190505b620002be620002b58562000153565b83018262000250565b50505b505050565b5f82821c905092915050565b5f620002e35f1984600802620002c6565b1980831691505092915050565b5f620002fd8383620002d2565b9150826002028217905092915050565b6200031882620000a9565b67ffffffffffffffff811115620003345762000333620000b3565b5b6200034082546200010d565b6200034d82828562000276565b5f60209050601f83116001811462000383575f84156200036e578287015190505b6200037a8582620002f0565b865550620003e9565b601f198416620003938662000141565b5f5b82811015620003bc5784890151825560018201915060208501945060208101905062000395565b86831015620003dc5784890151620003d8601f891682620002d2565b8355505b6001600288020188555050505b505050505050565b610b0d80620003ff5f395ff3fe608060405234801561000f575f80fd5b506004361061009c575f3560e01c806340c10f191161006457806340c10f191461015a57806370a082311461017657806395d89b41146101a6578063a9059cbb146101c4578063dd62ed3e146101f45761009c565b806306fdde03146100a0578063095ea7b3146100be57806318160ddd146100ee57806323b872dd1461010c578063313ce5671461013c575b5f80fd5b6100a8610224565b6040516100b59190610786565b60405180910390f35b6100d860048036038101906100d39190610837565b6102b4565b6040516100e5919061088f565b60405180910390f35b6100f66102bb565b60405161010391906108b7565b60405180910390f35b610126600480360381019061012191906108d0565b6102c4565b604051610133919061088f565b60405180910390f35b6101446102cc565b604051610151919061093b565b60405180910390f35b610174600480360381019061016f9190610837565b6102d4565b005b610190600480360381019061018b9190610954565b6102e2565b60405161019d91906108b7565b60405180910390f35b6101ae6102f3565b6040516101bb9190610786565b60405180910390f35b6101de60048036038101906101d99190610837565b610383565b6040516101eb919061088f565b60405180910390f35b61020e6004803603810190610209919061097f565b61038a565b60405161021b91906108b7565b60405180910390f35b606060038054610233906109ea565b80601f016020809104026020016040519081016040528092919081815260200182805461025f906109ea565b80156102aa5780601f10610281576101008083540402835291602001916102aa565b820191905f5260205f20905b81548152906001019060200180831161028d57829003601f168201915b5050505050905090565b5f92915050565b5f600254905090565b5f9392505050565b5f6012905090565b6102de828261039d565b5050565b5f6102ec8261041c565b9050919050565b606060048054610302906109ea565b80601f016020809104026020016040519081016040528092919081815260200182805461032e906109ea565b80156103795780601f1061035057610100808354040283529160200191610379565b820191905f5260205f20905b81548152906001019060200180831161035c57829003601f168201915b5050505050905090565b5f92915050565b5f6103958383610461565b905092915050565b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff160361040d575f6040517fec442f050000000000000000000000000000000000000000000000000000000081526004016104049190610a29565b60405180910390fd5b6104185f83836104e3565b5050565b5f805f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20549050919050565b5f60015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2054905092915050565b5f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610533578060025f8282546105279190610a6f565b92505081905550610601565b5f805f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20549050818110156105bc578381836040517fe450d38c0000000000000000000000000000000000000000000000000000000081526004016105b393929190610aa2565b60405180910390fd5b8181035f808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2081905550505b5f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610648578060025f8282540392505081905550610692565b805f808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f82825401925050819055505b8173ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef836040516106ef91906108b7565b60405180910390a3505050565b5f81519050919050565b5f82825260208201905092915050565b5f5b83811015610733578082015181840152602081019050610718565b5f8484015250505050565b5f601f19601f8301169050919050565b5f610758826106fc565b6107628185610706565b9350610772818560208601610716565b61077b8161073e565b840191505092915050565b5f6020820190508181035f83015261079e818461074e565b905092915050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6107d3826107aa565b9050919050565b6107e3816107c9565b81146107ed575f80fd5b50565b5f813590506107fe816107da565b92915050565b5f819050919050565b61081681610804565b8114610820575f80fd5b50565b5f813590506108318161080d565b92915050565b5f806040838503121561084d5761084c6107a6565b5b5f61085a858286016107f0565b925050602061086b85828601610823565b9150509250929050565b5f8115159050919050565b61088981610875565b82525050565b5f6020820190506108a25f830184610880565b92915050565b6108b181610804565b82525050565b5f6020820190506108ca5f8301846108a8565b92915050565b5f805f606084860312156108e7576108e66107a6565b5b5f6108f4868287016107f0565b9350506020610905868287016107f0565b925050604061091686828701610823565b9150509250925092565b5f60ff82169050919050565b61093581610920565b82525050565b5f60208201905061094e5f83018461092c565b92915050565b5f60208284031215610969576109686107a6565b5b5f610976848285016107f0565b91505092915050565b5f8060408385031215610995576109946107a6565b5b5f6109a2858286016107f0565b92505060206109b3858286016107f0565b9150509250929050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f6002820490506001821680610a0157607f821691505b602082108103610a1457610a136109bd565b5b50919050565b610a23816107c9565b82525050565b5f602082019050610a3c5f830184610a1a565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610a7982610804565b9150610a8483610804565b9250828201905080821115610a9c57610a9b610a42565b5b92915050565b5f606082019050610ab55f830186610a1a565b610ac260208301856108a8565b610acf60408301846108a8565b94935050505056fea26469706673582212204aac6dd6254b82f37f30add0ed2937474eced0bafc505b611f66b99ebe39999e64736f6c63430008150033")] + // SPDX-License-Identifier: MIT + contract ERC20ReturnFalseMock is ERC20 { + constructor() ERC20("ERC20ReturnFalseMock", "RFM") {} + + function approve(address, uint256) public override returns (bool) { + return false; + } + + function transfer(address, uint256) public override returns (bool) { + return false; + } + + function transferFrom(address, address, uint256) public override returns (bool) { + return false; + } + + function balanceOf(address account) public override view returns (uint256) { + return super.balanceOf(account); + } + + function mint(address account, uint256 value) public { + super._mint(account, value); + } + + function allowance(address owner, address spender) public view override returns (uint256) { + return super.allowance(owner, spender); + } + } +} + +pub async fn deploy(wallet: &Wallet) -> eyre::Result
{ + // Deploy the contract. + let contract = ERC20ReturnFalseMock::deploy(wallet).await?; + Ok(*contract.address()) +} diff --git a/examples/vesting-wallet/tests/mock/mod.rs b/examples/vesting-wallet/tests/mock/mod.rs new file mode 100644 index 000000000..8d39a8309 --- /dev/null +++ b/examples/vesting-wallet/tests/mock/mod.rs @@ -0,0 +1,2 @@ +pub mod erc20; +pub mod erc20_return_false; diff --git a/examples/vesting-wallet/tests/vesting-wallet.rs b/examples/vesting-wallet/tests/vesting-wallet.rs new file mode 100644 index 000000000..50f0b48e8 --- /dev/null +++ b/examples/vesting-wallet/tests/vesting-wallet.rs @@ -0,0 +1,475 @@ +#![cfg(feature = "e2e")] + +use abi::VestingWallet; +use alloy::{ + eips::BlockId, + primitives::{Address, U256}, + providers::Provider, + rpc::types::BlockTransactionsKind, + sol, +}; +use e2e::{ + receipt, send, watch, Account, EventExt, Panic, PanicCode, ReceiptExt, + Revert, +}; +use mock::{erc20, erc20::ERC20Mock}; + +use crate::VestingWalletExample::constructorCall; + +mod abi; +mod mock; + +sol!("src/constructor.sol"); + +const BALANCE: u64 = 1000; +const DURATION: u64 = 365 * 86400; // 1 year + +fn ctr( + beneficiary: Address, + start_timestamp: u64, + duration_seconds: u64, +) -> constructorCall { + constructorCall { + beneficiary, + startTimestamp: start_timestamp, + durationSeconds: duration_seconds, + } +} + +async fn block_timestamp(account: &Account) -> eyre::Result { + let timestamp = account + .wallet + .get_block(BlockId::latest(), BlockTransactionsKind::Full) + .await? + .expect("latest block should exist") + .header + .timestamp; + + Ok(timestamp) +} + +/// Since the block timestamp can theoretically change between the initial fetch +/// (to calculate the `start` timestamp) and the final release of vested funds +/// in the test, it is best we assert that the released amount is within +/// some predefined range. +/// The reason why the timestamp can change is that we perform many mutations +/// on-chain, from deploying and activating contracts, sending initial ETH/ERC20 +/// to the contract and then finally releasing the funds. +fn assert_in_delta(expected: U256, actual: U256) { + let diff = expected.abs_diff(actual); + let delta = U256::from(1); + assert!(diff <= delta, "Your result of {actual} should be within {delta} of the expected result {expected}"); +} + +#[e2e::test] +async fn constructs(alice: Account) -> eyre::Result<()> { + let start_timestamp = block_timestamp(&alice).await?; + let contract_addr = alice + .as_deployer() + .with_constructor(ctr(alice.address(), start_timestamp, DURATION)) + .deploy() + .await? + .address()?; + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let owner = contract.owner().call().await?.owner; + let start = contract.start().call().await?.start; + let duration = contract.duration().call().await?.duration; + let end = contract.end().call().await?.end; + + assert_eq!(alice.address(), owner); + assert_eq!(U256::from(start_timestamp), start); + assert_eq!(U256::from(DURATION), duration); + assert_eq!(U256::from(start_timestamp + DURATION), end); + + Ok(()) +} + +#[e2e::test] +async fn rejects_zero_address_for_beneficiary( + alice: Account, +) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let err = alice + .as_deployer() + .with_constructor(ctr(Address::ZERO, start, DURATION)) + .deploy() + .await + .expect_err("should not deploy due to `OwnableInvalidOwner`"); + + assert!(err.reverted_with(VestingWallet::OwnableInvalidOwner { + owner: Address::ZERO + })); + + Ok(()) +} + +mod ether_vesting { + use super::*; + + async fn deploy( + account: &Account, + start: u64, + duration: u64, + allocation: u64, + ) -> eyre::Result
{ + let contract_addr = account + .as_deployer() + .with_constructor(ctr(account.address(), start, duration)) + .deploy() + .await? + .address()?; + let contract = VestingWallet::new(contract_addr, &account.wallet); + + let _ = watch!(contract.receiveEther().value(U256::from(allocation)))?; + + Ok(contract_addr) + } + + async fn run_check_release( + alice: Account, + time_passed: u64, + ) -> eyre::Result<()> { + let timestamp = block_timestamp(&alice).await?; + let start = timestamp - time_passed; + let expected_amount = U256::from(std::cmp::min( + BALANCE, + BALANCE * time_passed / DURATION, + )); + let contract_addr = deploy(&alice, start, DURATION, BALANCE).await?; + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let old_alice_balance = + alice.wallet.get_balance(alice.address()).await?; + let old_contract_balance = + alice.wallet.get_balance(contract_addr).await?; + + let released = contract.released_0().call().await?.released; + let releasable = contract.releasable_0().call().await?.releasable; + assert_eq!(U256::ZERO, released); + assert_in_delta(expected_amount, releasable); + + let receipt = receipt!(contract.release_0())?; + + let alice_balance = alice.wallet.get_balance(alice.address()).await?; + let contract_balance = alice.wallet.get_balance(contract_addr).await?; + let released = contract.released_0().call().await?.released; + let releasable = contract.releasable_0().call().await?.releasable; + assert_in_delta(expected_amount, released); + assert_in_delta(U256::ZERO, releasable); + assert_in_delta( + old_alice_balance + released + - U256::from(receipt.gas_used * receipt.effective_gas_price), + alice_balance, + ); + assert_in_delta(old_contract_balance - released, contract_balance); + + assert!( + receipt.emits(VestingWallet::EtherReleased { amount: released }) + ); + + Ok(()) + } + + #[e2e::test] + async fn check_release_0_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, 0).await + } + + #[e2e::test] + async fn check_release_25_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, DURATION / 4).await + } + + #[e2e::test] + async fn check_release_50_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, DURATION / 2).await + } + + #[e2e::test] + async fn check_release_100_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, DURATION).await + } + + #[e2e::test] + async fn check_release_100_percent_vesting_in_the_past( + alice: Account, + ) -> eyre::Result<()> { + run_check_release(alice, DURATION * 4 / 3).await + } + + #[e2e::test] + async fn check_vested_amount(alice: Account) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let contract_addr = deploy(&alice, start, DURATION, BALANCE).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + for i in 0..64 { + let timestamp = i * DURATION / 60 + start; + let expected_amount = U256::from(std::cmp::min( + BALANCE, + BALANCE * (timestamp - start) / DURATION, + )); + + let vested_amount = + contract.vestedAmount_0(timestamp).call().await?.vestedAmount; + assert_eq!( + expected_amount, vested_amount, + "\n---\ni: {i}\nstart: {start}\ntimestamp: {timestamp}\n---\n" + ); + } + + Ok(()) + } +} + +mod erc20_vesting { + use super::*; + + async fn deploy( + account: &Account, + start: u64, + duration: u64, + ) -> eyre::Result
{ + let contract_addr = account + .as_deployer() + .with_constructor(ctr(account.address(), start, duration)) + .deploy() + .await? + .address()?; + Ok(contract_addr) + } + + async fn deploy_erc20( + account: &Account, + mint_to: Address, + allocation: U256, + ) -> eyre::Result
{ + let erc20_address = erc20::deploy(&account.wallet).await?; + let erc20 = ERC20Mock::new(erc20_address, &account.wallet); + let _ = watch!(erc20.mint(mint_to, allocation))?; + Ok(erc20_address) + } + + async fn deploy_erc20_return_false( + account: &Account, + mint_to: Address, + allocation: u64, + ) -> eyre::Result
{ + use mock::{ + erc20_return_false, erc20_return_false::ERC20ReturnFalseMock, + }; + + let erc20_address = erc20_return_false::deploy(&account.wallet).await?; + let erc20 = ERC20ReturnFalseMock::new(erc20_address, &account.wallet); + let _ = watch!(erc20.mint(mint_to, U256::from(allocation)))?; + Ok(erc20_address) + } + + async fn run_check_release( + alice: Account, + time_passed: u64, + ) -> eyre::Result<()> { + let timestamp = block_timestamp(&alice).await?; + let start = timestamp - time_passed; + let expected_amount = U256::from(std::cmp::min( + BALANCE, + BALANCE * time_passed / DURATION, + )); + let contract_addr = deploy(&alice, start, DURATION).await?; + let erc20_address = + deploy_erc20(&alice, contract_addr, U256::from(BALANCE)).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + let erc20 = ERC20Mock::new(erc20_address, &alice.wallet); + + let old_alice_balance = + erc20.balanceOf(alice.address()).call().await?.balance; + let old_contract_balance = + erc20.balanceOf(contract_addr).call().await?.balance; + + let released = + contract.released_1(erc20_address).call().await?.released; + let releasable = + contract.releasable_1(erc20_address).call().await?.releasable; + assert_eq!(U256::ZERO, released); + assert_in_delta(expected_amount, releasable); + + let receipt = receipt!(contract.release_1(erc20_address))?; + + let alice_balance = + erc20.balanceOf(alice.address()).call().await?.balance; + let contract_balance = + erc20.balanceOf(contract_addr).call().await?.balance; + let released = + contract.released_1(erc20_address).call().await?.released; + let releasable = + contract.releasable_1(erc20_address).call().await?.releasable; + assert_in_delta(expected_amount, released); + assert_in_delta(U256::ZERO, releasable); + assert_in_delta(old_alice_balance + released, alice_balance); + assert_in_delta(old_contract_balance - released, contract_balance); + + assert!( + receipt.emits(VestingWallet::EtherReleased { amount: released }) + ); + + Ok(()) + } + + #[e2e::test] + async fn check_release_0_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, 0).await + } + + #[e2e::test] + async fn check_release_25_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, DURATION / 4).await + } + + #[e2e::test] + async fn check_release_50_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, DURATION / 2).await + } + + #[e2e::test] + async fn check_release_100_percent(alice: Account) -> eyre::Result<()> { + run_check_release(alice, DURATION).await + } + + #[e2e::test] + async fn check_release_100_percent_vesting_in_the_past( + alice: Account, + ) -> eyre::Result<()> { + run_check_release(alice, DURATION * 4 / 3).await + } + + #[e2e::test] + async fn check_vested_amount(alice: Account) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let contract_addr = deploy(&alice, start, DURATION).await?; + let erc20_address = + deploy_erc20(&alice, contract_addr, U256::from(BALANCE)).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + for i in 0..64 { + let timestamp = i * DURATION / 60 + start; + let expected_amount = U256::from(std::cmp::min( + BALANCE, + BALANCE * (timestamp - start) / DURATION, + )); + + let vested_amount = contract + .vestedAmount_1(erc20_address, timestamp) + .call() + .await? + .vestedAmount; + assert_eq!( + expected_amount, vested_amount, + "\n---\ni: {i}\nstart: {start}\ntimestamp: {timestamp}\n---\n" + ); + } + + Ok(()) + } + + #[e2e::test] + async fn releasable_erc20_reverts_on_invalid_token( + alice: Account, + ) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let contract_addr = deploy(&alice, start, DURATION).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let err = send!(contract.releasable_1(Address::ZERO)) + .expect_err("should not get releasable amount for invalid token"); + + assert!(err.reverted_with(VestingWallet::InvalidToken { + token: Address::ZERO + })); + + Ok(()) + } + + #[e2e::test] + async fn release_erc20_reverts_on_invalid_token( + alice: Account, + ) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let contract_addr = deploy(&alice, start, DURATION).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let err = send!(contract.release_1(Address::ZERO)) + .expect_err("should not release for invalid token"); + + assert!(err.reverted_with(VestingWallet::InvalidToken { + token: Address::ZERO + })); + + Ok(()) + } + + #[e2e::test] + async fn release_erc20_reverts_on_failed_transfer( + alice: Account, + ) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let contract_addr = deploy(&alice, start, DURATION).await?; + let erc20_address = + deploy_erc20_return_false(&alice, contract_addr, BALANCE).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let err = send!(contract.release_1(erc20_address)) + .expect_err("should not release when transfer fails"); + + assert!(err.reverted_with(VestingWallet::SafeErc20FailedOperation { + token: erc20_address + })); + + Ok(()) + } + + #[e2e::test] + async fn vested_amount_erc20_reverts_on_invalid_token( + alice: Account, + ) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let contract_addr = deploy(&alice, start, DURATION).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let err = send!(contract.vestedAmount_1(Address::ZERO, start)) + .expect_err("should not get vested amount for invalid token"); + + assert!(err.reverted_with(VestingWallet::InvalidToken { + token: Address::ZERO + })); + + Ok(()) + } + + #[e2e::test] + async fn vested_amount_reverts_on_scaled_allocation_overflow( + alice: Account, + ) -> eyre::Result<()> { + let start = block_timestamp(&alice).await?; + let timestamp = DURATION / 2 + start; + let contract_addr = deploy(&alice, start, DURATION).await?; + let erc20_address = + deploy_erc20(&alice, contract_addr, U256::MAX).await?; + + let contract = VestingWallet::new(contract_addr, &alice.wallet); + + let err = send!(contract.vestedAmount_1(erc20_address, timestamp)) + .expect_err("should exceed `U256::MAX`"); + + assert!(err.panicked_with(PanicCode::ArithmeticOverflow)); + + Ok(()) + } +}