diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 65f6c9bc7..f913f8ee2 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -30,6 +30,7 @@ erc20 = [] erc20_burnable = ["erc20"] erc20_capped = ["erc20"] erc20_metadata = ["erc20"] +erc20_pausable = ["erc20"] # ERC-721 erc721 = [] diff --git a/contracts/src/erc20/extensions/burnable.rs b/contracts/src/erc20/extensions/burnable.rs index ee122b8c9..ddd49c353 100644 --- a/contracts/src/erc20/extensions/burnable.rs +++ b/contracts/src/erc20/extensions/burnable.rs @@ -1,227 +1,179 @@ //! Optional Burnable extension of the ERC-20 standard. -/// This macro provides an implementation of the ERC-20 Burnable extension. -/// -/// It adds the `burn` and `burn_from` functions, and expects the token -/// to contain `ERC20 erc20` as a field. See [`crate::ERC20`]. -#[macro_export] -macro_rules! erc20_burnable_impl { - () => { - /// Destroys a `value` amount of tokens from the caller. - /// lowering the total supply. - /// - /// Relies on the `update` mechanism. - /// - /// # Arguments - /// - /// * `value` - Amount to be burnt. - /// - /// # Errors - /// - /// If the `from` address doesn't have enough tokens, then the error - /// [`Error::InsufficientBalance`] is returned. - /// - /// # Events - /// - /// Emits a [`Transfer`] event. - pub(crate) fn burn( - &mut self, - value: alloy_primitives::U256, - ) -> Result<(), alloc::vec::Vec> { - self.erc20 - ._burn(stylus_sdk::msg::sender(), value) - .map_err(|e| e.into()) - } - - /// Destroys a `value` amount of tokens from `account`, - /// lowering the total supply. - /// - /// Relies on the `update` mechanism. - /// - /// # Arguments - /// - /// * `account` - Owner's address. - /// * `value` - Amount to be burnt. - /// - /// # Errors - /// - /// If not enough allowance is available, then the error - /// [`Error::InsufficientAllowance`] is returned. - /// * If the `from` address is `Address::ZERO`, then the error - /// [`Error::InvalidSender`] is returned. - /// If the `from` address doesn't have enough tokens, then the error - /// [`Error::InsufficientBalance`] is returned. - /// - /// # Events - /// - /// Emits a [`Transfer`] event. - pub(crate) fn burn_from( - &mut self, - account: alloy_primitives::Address, - value: alloy_primitives::U256, - ) -> Result<(), alloc::vec::Vec> { - self.erc20._spend_allowance( - account, - stylus_sdk::msg::sender(), - value, - )?; - self.erc20._burn(account, value).map_err(|e| e.into()) - } - }; +use alloy_primitives::{Address, U256}; +use stylus_sdk::msg; + +use crate::erc20::{Error, ERC20}; + +/// Extension of [`ERC20`] that allows token holders to destroy both +/// their own tokens and those that they have an allowance for, +/// in a way that can be recognized off-chain (via event analysis). +#[allow(clippy::module_name_repetitions)] +pub trait IERC20Burnable { + /// Destroys a `value` amount of tokens from the caller. + /// lowering the total supply. + /// + /// Relies on the `update` mechanism. + /// + /// # Arguments + /// + /// * `value` - Amount to be burnt. + /// + /// # Errors + /// + /// If the `from` address doesn't have enough tokens, then the error + /// [`Error::InsufficientBalance`] is returned. + /// + /// # Events + /// + /// Emits a [`Transfer`] event. + fn burn(&mut self, value: U256) -> Result<(), Error>; + + /// Destroys a `value` amount of tokens from `account`, + /// lowering the total supply. + /// + /// Relies on the `update` mechanism. + /// + /// # Arguments + /// + /// * `account` - Owner's address. + /// * `value` - Amount to be burnt. + /// + /// # Errors + /// + /// If not enough allowance is available, then the error + /// [`Error::InsufficientAllowance`] is returned. + /// * If the `from` address is `Address::ZERO`, then the error + /// [`Error::InvalidSender`] is returned. + /// If the `from` address doesn't have enough tokens, then the error + /// [`Error::InsufficientBalance`] is returned. + /// + /// # Events + /// + /// Emits a [`Transfer`] event. + fn burn_from(&mut self, account: Address, value: U256) + -> Result<(), Error>; } -#[cfg(test)] -mod tests { - use alloy_primitives::{address, Address, U256}; - use stylus_sdk::{msg, prelude::*}; - - use crate::erc20::{ - ERC20InsufficientAllowance, ERC20InsufficientBalance, - ERC20InvalidSender, Error, ERC20, - }; - - sol_storage! { - pub struct TestERC20Burnable { - ERC20 erc20; - } +impl IERC20Burnable for ERC20 { + fn burn(&mut self, value: U256) -> Result<(), Error> { + self._burn(msg::sender(), value) } - #[external] - #[inherit(ERC20)] - impl TestERC20Burnable { - erc20_burnable_impl!(); + fn burn_from( + &mut self, + account: Address, + value: U256, + ) -> Result<(), Error> { + self._spend_allowance(account, msg::sender(), value)?; + self._burn(account, value) } +} - impl Default for TestERC20Burnable { - fn default() -> Self { - Self { erc20: ERC20::default() } - } - } +#[cfg(test)] +mod tests { + use alloy_primitives::{address, Address, U256}; + use stylus_sdk::msg; + + use super::IERC20Burnable; + use crate::erc20::{Error, ERC20}; #[grip::test] - fn burns(contract: TestERC20Burnable) { + fn burns(contract: ERC20) { let zero = U256::ZERO; let one = U256::from(1); - assert_eq!(zero, contract.erc20.total_supply()); + assert_eq!(zero, contract.total_supply()); // Mint some tokens for msg::sender(). let sender = msg::sender(); let two = U256::from(2); - contract.erc20._update(Address::ZERO, sender, two).unwrap(); - assert_eq!(two, contract.erc20.balance_of(sender)); - assert_eq!(two, contract.erc20.total_supply()); + contract._update(Address::ZERO, sender, two).unwrap(); + assert_eq!(two, contract.balance_of(sender)); + assert_eq!(two, contract.total_supply()); contract.burn(one).unwrap(); - assert_eq!(one, contract.erc20.balance_of(sender)); - assert_eq!(one, contract.erc20.total_supply()); + assert_eq!(one, contract.balance_of(sender)); + assert_eq!(one, contract.total_supply()); } #[grip::test] - fn burns_errors_when_insufficient_balance(contract: TestERC20Burnable) { + fn burns_errors_when_insufficient_balance(contract: ERC20) { let zero = U256::ZERO; let one = U256::from(1); let sender = msg::sender(); - assert_eq!(zero, contract.erc20.balance_of(sender)); + assert_eq!(zero, contract.balance_of(sender)); let result = contract.burn(one); - let expected_err: alloc::vec::Vec = - Error::InsufficientBalance(ERC20InsufficientBalance { - sender, - balance: zero, - needed: one, - }) - .into(); - - assert_eq!(result.unwrap_err(), expected_err); + assert!(matches!(result, Err(Error::InsufficientBalance(_)))); } + #[grip::test] - fn burn_from(contract: TestERC20Burnable) { + fn burn_from(contract: ERC20) { let alice = address!("A11CEacF9aa32246d767FCCD72e02d6bCbcC375d"); let sender = msg::sender(); // Alice approves `msg::sender`. let one = U256::from(1); - contract.erc20._allowances.setter(alice).setter(sender).set(one); + contract._allowances.setter(alice).setter(sender).set(one); // Mint some tokens for Alice. let two = U256::from(2); - contract.erc20._update(Address::ZERO, alice, two).unwrap(); - assert_eq!(two, contract.erc20.balance_of(alice)); - assert_eq!(two, contract.erc20.total_supply()); + contract._update(Address::ZERO, alice, two).unwrap(); + assert_eq!(two, contract.balance_of(alice)); + assert_eq!(two, contract.total_supply()); contract.burn_from(alice, one).unwrap(); - assert_eq!(one, contract.erc20.balance_of(alice)); - assert_eq!(one, contract.erc20.total_supply()); - assert_eq!(U256::ZERO, contract.erc20.allowance(alice, sender)); + assert_eq!(one, contract.balance_of(alice)); + assert_eq!(one, contract.total_supply()); + assert_eq!(U256::ZERO, contract.allowance(alice, sender)); } #[grip::test] - fn burns_from_errors_when_insufficient_balance( - contract: TestERC20Burnable, - ) { + fn burns_from_errors_when_insufficient_balance(contract: ERC20) { let alice = address!("A11CEacF9aa32246d767FCCD72e02d6bCbcC375d"); // Alice approves `msg::sender`. let zero = U256::ZERO; let one = U256::from(1); - contract.erc20._allowances.setter(alice).setter(msg::sender()).set(one); - assert_eq!(zero, contract.erc20.balance_of(alice)); + + contract._allowances.setter(alice).setter(msg::sender()).set(one); + assert_eq!(zero, contract.balance_of(alice)); let one = U256::from(1); + let result = contract.burn_from(alice, one); - let expected_err: alloc::vec::Vec = - Error::InsufficientBalance(ERC20InsufficientBalance { - sender: alice, - balance: zero, - needed: one, - }) - .into(); - - assert_eq!(result.unwrap_err(), expected_err); + assert!(matches!(result, Err(Error::InsufficientBalance(_)))); } #[grip::test] - fn burns_from_errors_when_invalid_sender(contract: TestERC20Burnable) { + fn burns_from_errors_when_invalid_sender(contract: ERC20) { let one = U256::from(1); + contract - .erc20 ._allowances .setter(Address::ZERO) .setter(msg::sender()) .set(one); - let result = contract.burn_from(Address::ZERO, one); - let expected_err: alloc::vec::Vec = - Error::InvalidSender(ERC20InvalidSender { sender: Address::ZERO }) - .into(); - assert_eq!(result.unwrap_err(), expected_err); + let result = contract.burn_from(Address::ZERO, one); + assert!(matches!(result, Err(Error::InvalidSender(_)))); } #[grip::test] - fn burns_from_errors_when_insufficient_allowance( - contract: TestERC20Burnable, - ) { + fn burns_from_errors_when_insufficient_allowance(contract: ERC20) { let alice = address!("A11CEacF9aa32246d767FCCD72e02d6bCbcC375d"); // Mint some tokens for Alice. let one = U256::from(1); - contract.erc20._update(Address::ZERO, alice, one).unwrap(); - assert_eq!(one, contract.erc20.balance_of(alice)); + contract._update(Address::ZERO, alice, one).unwrap(); + assert_eq!(one, contract.balance_of(alice)); let result = contract.burn_from(alice, one); - let expected_err: alloc::vec::Vec = - Error::InsufficientAllowance(ERC20InsufficientAllowance { - spender: msg::sender(), - allowance: U256::ZERO, - needed: one, - }) - .into(); - - assert_eq!(result.unwrap_err(), expected_err); + assert!(matches!(result, Err(Error::InsufficientAllowance(_)))); } } diff --git a/contracts/src/erc20/extensions/mod.rs b/contracts/src/erc20/extensions/mod.rs index e4d73e38d..d0233de11 100644 --- a/contracts/src/erc20/extensions/mod.rs +++ b/contracts/src/erc20/extensions/mod.rs @@ -9,6 +9,7 @@ cfg_if::cfg_if! { cfg_if::cfg_if! { if #[cfg(any(test, feature = "erc20_burnable"))] { pub mod burnable; + pub use burnable::IERC20Burnable; } } diff --git a/contracts/src/utils/mod.rs b/contracts/src/utils/mod.rs index 55c05556f..e0cfc31d8 100644 --- a/contracts/src/utils/mod.rs +++ b/contracts/src/utils/mod.rs @@ -6,3 +6,10 @@ cfg_if::cfg_if! { pub use metadata::Metadata; } } + +cfg_if::cfg_if! { + if #[cfg(any(test, feature = "erc20_pausable"))] { + pub mod pausable; + pub use pausable::Pausable; + } +} diff --git a/contracts/src/utils/pausable.rs b/contracts/src/utils/pausable.rs new file mode 100644 index 000000000..135c7bea4 --- /dev/null +++ b/contracts/src/utils/pausable.rs @@ -0,0 +1,302 @@ +//! Pausable Contract. +//! +//! Contract module which allows implementing an emergency stop mechanism +//! that can be triggered by an authorized account. +//! +//! It provides functions [`Pausable::when_not_paused`] +//! and [`Pausable::when_paused`], +//! which can be added to the functions of your contract. +//! +//! Note that they will not be pausable by simply including this module, +//! only once the modifiers are put in place. + +use alloy_sol_types::sol; +use stylus_proc::{external, sol_storage, SolidityError}; +use stylus_sdk::{evm, msg}; + +sol! { + /// Emitted when pause is triggered by `account`. + #[allow(missing_docs)] + event Paused(address account); + + /// Emitted when the pause is lifted by `account`. + #[allow(missing_docs)] + event Unpaused(address account); +} + +sol! { + /// Indicates an error related to the operation that failed + /// because the contract is paused. + #[derive(Debug)] + #[allow(missing_docs)] + error EnforcedPause(); + + /// Indicates an error related to the operation that failed + /// because the contract is not paused. + #[derive(Debug)] + #[allow(missing_docs)] + error ExpectedPause(); +} + +/// A Pausable error. +#[derive(SolidityError, Debug)] +pub enum Error { + /// Indicates an error related to the operation that failed + /// because the contract had been in `Paused` state. + EnforcedPause(EnforcedPause), + /// Indicates an error related to the operation that failed + /// because the contract had been in `Unpaused` state. + ExpectedPause(ExpectedPause), +} + +sol_storage! { + /// State of a Pausable Contract. + #[allow(missing_docs)] + pub struct Pausable { + /// Indicates whether the contract is `Paused`. + bool _paused; + /// Initialization marker. If true this means that the constructor was + /// called. + /// + /// This field should be unnecessary once constructors are supported in + /// the SDK. + bool _initialized; + } +} + +#[external] +impl Pausable { + /// Initializes a [`Pausable`] contract with the passed `paused`. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `paused` - Indicates if contract is paused. + /// + /// # Panics + /// + /// * If the contract is already initialized, then this function panics. + /// This ensures the contract is constructed only once. + pub fn constructor(&mut self, paused: bool) { + let is_initialized = self._initialized.get(); + assert!(!is_initialized, "Pausable has already been initialized"); + + self._paused.set(paused); + self._initialized.set(true); + } + + /// Returns true if the contract is paused, and false otherwise. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + fn paused(&self) -> bool { + self._paused.get() + } + + /// Triggers `Paused` state. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// + /// # Errors + /// + /// * If the contract is in `Paused` state, then the error + /// [`Error::EnforcedPause`] is returned. + + pub fn pause(&mut self) -> Result<(), Error> { + self.when_not_paused()?; + self._paused.set(true); + evm::log(Paused { account: msg::sender() }); + Ok(()) + } + + /// Triggers `Unpaused` state. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// + /// # Errors + /// + /// * If the contract is in `Unpaused` state, then the error + /// [`Error::ExpectedPause`] is returned. + pub fn unpause(&mut self) -> Result<(), Error> { + self.when_paused()?; + self._paused.set(false); + evm::log(Unpaused { account: msg::sender() }); + Ok(()) + } + + /// Modifier to make a function callable + /// only when the contract is NOT paused. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// + /// # Errors + /// + /// * If the contract is in `Paused` state, then the error + /// [`Error::EnforcedPause`] is returned. + pub fn when_not_paused(&self) -> Result<(), Error> { + if self._paused.get() { + return Err(Error::EnforcedPause(EnforcedPause {})); + } + Ok(()) + } + + /// Modifier to make a function callable + /// only when the contract is paused. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// + /// # Errors + /// + /// * If the contract is in `Unpaused` state, then the error + /// [`Error::ExpectedPause`] is returned. + pub fn when_paused(&self) -> Result<(), Error> { + if !self._paused.get() { + return Err(Error::ExpectedPause(ExpectedPause {})); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::U256; + use stylus_sdk::storage::{StorageBool, StorageType}; + + use crate::utils::pausable::{Error, Pausable}; + + impl Default for Pausable { + fn default() -> Self { + let root = U256::ZERO; + Pausable { + _paused: unsafe { StorageBool::new(root, 0) }, + _initialized: unsafe { + StorageBool::new(root + U256::from(32), 0) + }, + } + } + } + + #[grip::test] + fn constructs(pausable: Pausable) { + assert_eq!(false, pausable._initialized.get()); + + let paused = false; + pausable.constructor(paused); + + assert_eq!(paused, pausable._paused.get()); + assert_eq!(true, pausable._initialized.get()); + } + + #[grip::test] + #[should_panic = "Pausable has already been initialized"] + fn constructs_only_once(pausable: Pausable) { + let paused = false; + pausable.constructor(paused); + pausable.constructor(paused); + } + + #[grip::test] + fn paused_works(contract: Pausable) { + // Check for unpaused + contract._paused.set(false); + assert_eq!(contract.paused(), false); + // Check for paused + contract._paused.set(true); + assert_eq!(contract.paused(), true); + } + + #[grip::test] + fn when_not_paused_works(contract: Pausable) { + // Check for unpaused + contract._paused.set(false); + assert_eq!(contract.paused(), false); + + let result = contract.when_not_paused(); + assert!(result.is_ok()); + } + + #[grip::test] + fn when_not_paused_errors_when_paused(contract: Pausable) { + // Check for paused + contract._paused.set(true); + assert_eq!(contract.paused(), true); + + let result = contract.when_not_paused(); + assert!(matches!(result, Err(Error::EnforcedPause(_)))); + } + + #[grip::test] + fn when_paused_works(contract: Pausable) { + // Check for unpaused + contract._paused.set(true); + assert_eq!(contract.paused(), true); + + let result = contract.when_paused(); + assert!(result.is_ok()); + } + + #[grip::test] + fn when_paused_errors_when_not_paused(contract: Pausable) { + // Check for paused + contract._paused.set(false); + assert_eq!(contract.paused(), false); + + let result = contract.when_paused(); + assert!(matches!(result, Err(Error::ExpectedPause(_)))); + } + + #[grip::test] + fn pause_works(contract: Pausable) { + // Check for unpaused + contract._paused.set(false); + assert_eq!(contract.paused(), false); + + // Pause the contract + contract.pause().expect("Pause action must work in unpaused state"); + assert_eq!(contract.paused(), true); + } + + #[grip::test] + fn pause_errors_when_already_paused(contract: Pausable) { + // Check for paused + contract._paused.set(true); + assert_eq!(contract.paused(), true); + + // Pause the paused contract + let result = contract.pause(); + assert!(matches!(result, Err(Error::EnforcedPause(_)))); + assert_eq!(contract.paused(), true); + } + + #[grip::test] + fn unpause_works(contract: Pausable) { + // Check for paused + contract._paused.set(true); + assert_eq!(contract.paused(), true); + + // Unpause the paused contract + contract.unpause().expect("Unpause action must work in paused state"); + assert_eq!(contract.paused(), false); + } + + #[grip::test] + fn unpause_errors_when_already_unpaused(contract: Pausable) { + // Check for unpaused + contract._paused.set(false); + assert_eq!(contract.paused(), false); + + // Unpause the unpaused contract + let result = contract.unpause(); + assert!(matches!(result, Err(Error::ExpectedPause(_)))); + assert_eq!(contract.paused(), false); + } +} diff --git a/examples/erc20/Cargo.toml b/examples/erc20/Cargo.toml index 9a58071dd..ef0427a29 100644 --- a/examples/erc20/Cargo.toml +++ b/examples/erc20/Cargo.toml @@ -7,7 +7,7 @@ publish = false version = "0.0.0" [dependencies] -contracts = { path = "../../contracts", features = ["erc20_metadata", "erc20_burnable", "erc20_capped"] } +contracts = {path = "../../contracts", features = ["erc20_burnable", "erc20_capped", "erc20_metadata", "erc20_pausable"]} alloy-primitives.workspace = true stylus-sdk.workspace = true stylus-proc.workspace = true diff --git a/examples/erc20/src/lib.rs b/examples/erc20/src/lib.rs index 467e0187c..eb69ffa53 100644 --- a/examples/erc20/src/lib.rs +++ b/examples/erc20/src/lib.rs @@ -6,10 +6,10 @@ use alloc::{string::String, vec::Vec}; use alloy_primitives::{Address, U256}; use contracts::{ erc20::{ - extensions::{capped, Capped, ERC20Metadata}, + extensions::{capped, Capped, ERC20Metadata, IERC20Burnable}, ERC20, }, - erc20_burnable_impl, + utils::Pausable, }; use stylus_sdk::prelude::{entrypoint, external, sol_storage}; @@ -24,16 +24,14 @@ sol_storage! { ERC20Metadata metadata; #[borrow] Capped capped; + #[borrow] + Pausable pausable; } } #[external] -#[inherit(ERC20, ERC20Metadata, Capped)] +#[inherit(ERC20, ERC20Metadata, Capped, Pausable)] impl Token { - // This macro implements ERC20Burnable functions -- `burn` and `burn_from`. - // Expects an `ERC20 erc20` as a field of `Token`. - erc20_burnable_impl!(); - // We need to properly initialize all Token's attributes. // For that we need to call each attributes' constructor if exists. // @@ -43,9 +41,11 @@ impl Token { name: String, symbol: String, cap: U256, + paused: bool, ) -> Result<(), Vec> { self.metadata.constructor(name, symbol); self.capped.constructor(cap)?; + self.pausable.constructor(paused); Ok(()) } @@ -57,6 +57,20 @@ impl Token { DECIMALS } + pub fn burn(&mut self, value: U256) -> Result<(), Vec> { + self.pausable.when_not_paused()?; + self.erc20.burn(value).map_err(|e| e.into()) + } + + pub fn burn_from( + &mut self, + account: Address, + value: U256, + ) -> Result<(), Vec> { + self.pausable.when_not_paused()?; + self.erc20.burn_from(account, value).map_err(|e| e.into()) + } + // Add token minting feature. // Make sure to handle `Capped` properly. // @@ -67,6 +81,7 @@ impl Token { account: Address, value: U256, ) -> Result<(), Vec> { + self.pausable.when_not_paused()?; let max_supply = self.capped.cap(); let supply = self.erc20.total_supply() + value; if supply > max_supply { @@ -81,4 +96,23 @@ impl Token { self.erc20._mint(account, value)?; Ok(()) } + + pub fn transfer( + &mut self, + to: Address, + value: U256, + ) -> Result> { + self.pausable.when_not_paused()?; + self.erc20.transfer(to, value).map_err(|e| e.into()) + } + + pub fn transfer_from( + &mut self, + from: Address, + to: Address, + value: U256, + ) -> Result> { + self.pausable.when_not_paused()?; + self.erc20.transfer_from(from, to, value).map_err(|e| e.into()) + } }