diff --git a/Cargo.lock b/Cargo.lock index c439676f7..7b730dad6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1582,6 +1582,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "erc20-permit-example" +version = "0.0.0" +dependencies = [ + "alloy", + "alloy-primitives 0.3.3", + "e2e", + "eyre", + "mini-alloc", + "openzeppelin-stylus", + "stylus-proc", + "stylus-sdk", + "tokio", +] + [[package]] name = "erc721-example" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index abd512c9a..fe95030bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "lib/e2e", "lib/e2e-proc", "examples/erc20", + "examples/erc20-permit", "examples/erc721", "examples/erc721-metadata", "examples/merkle-proofs", @@ -24,6 +25,7 @@ default-members = [ "lib/motsu-proc", "lib/e2e-proc", "examples/erc20", + "examples/erc20-permit", "examples/erc721", "examples/erc721-metadata", "examples/merkle-proofs", diff --git a/contracts/src/token/erc20/extensions/mod.rs b/contracts/src/token/erc20/extensions/mod.rs index 5cf55691a..beaa80ecb 100644 --- a/contracts/src/token/erc20/extensions/mod.rs +++ b/contracts/src/token/erc20/extensions/mod.rs @@ -2,7 +2,9 @@ pub mod burnable; pub mod capped; pub mod metadata; +pub mod permit; pub use burnable::IErc20Burnable; pub use capped::Capped; pub use metadata::{Erc20Metadata, IErc20Metadata}; +pub use permit::Erc20Permit; diff --git a/contracts/src/token/erc20/extensions/permit.rs b/contracts/src/token/erc20/extensions/permit.rs new file mode 100644 index 000000000..c7d61aae0 --- /dev/null +++ b/contracts/src/token/erc20/extensions/permit.rs @@ -0,0 +1,329 @@ +//! Permit Contract. +//! +//! Extension of the ERC-20 standard allowing approvals to be made +//! via signatures, as defined in EIP-2612. +//! +//! Adds the `permit` method, which can be used to change an account’s +//! ERC20 allowance (see [`crate::token::erc20::IErc20::allowance`]) +//! by presenting a message signed by the account. +//! By not relying on [`crate::token::erc20::IErc20::approve`], +//! the token holder account doesn’t need to send a transaction, +//! and thus is not required to hold Ether at all. +use alloy_primitives::{b256, keccak256, Address, B256, U256}; +use alloy_sol_types::{sol, SolType}; +use stylus_proc::{external, sol_storage, SolidityError}; +use stylus_sdk::{ + block, call::MethodError, prelude::StorageType, storage::TopLevelStorage, +}; + +use crate::{ + token::erc20::{self, Erc20, IErc20}, + utils::{ + cryptography::{ecdsa, eip712::IEip712}, + nonces::Nonces, + }, +}; + +// keccak256("Permit(address owner,address spender,uint256 value,uint256 +// nonce,uint256 deadline)") +const PERMIT_TYPEHASH: B256 = + b256!("6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9"); + +type StructHashTuple = sol! { + tuple(bytes32, address, address, uint256, uint256, uint256) +}; + +sol! { + /// Indicates an error related to the fact that + /// permit deadline has expired. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC2612ExpiredSignature(uint256 deadline); + + /// Indicates an error related to the issue about mismatched signature. + #[derive(Debug)] + #[allow(missing_docs)] + error ERC2612InvalidSigner(address signer, address owner); +} + +/// A Permit error. +#[derive(SolidityError, Debug)] +pub enum Error { + /// Indicates an error related to the fact that + /// permit deadline has expired. + ExpiredSignature(ERC2612ExpiredSignature), + /// Indicates an error related to the issue about mismatched signature. + InvalidSigner(ERC2612InvalidSigner), + /// Error type from [`Erc20`] contract [`erc20::Error`]. + Erc20(erc20::Error), + /// Error type from [`ecdsa`] contract [`ecdsa::Error`]. + ECDSA(ecdsa::Error), +} + +impl MethodError for erc20::Error { + fn encode(self) -> alloc::vec::Vec { + self.into() + } +} + +impl MethodError for ecdsa::Error { + fn encode(self) -> alloc::vec::Vec { + self.into() + } +} + +sol_storage! { + /// State of a Permit Contract. + pub struct Erc20Permit{ + /// ERC-20 contract. + Erc20 erc20; + + /// Nonces contract. + Nonces nonces; + + /// EIP-712 contract. Must implement [`IEip712`] trait. + T eip712; + } +} + +/// 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 Erc20Permit {} + +#[external] +impl Erc20Permit { + /// Returns the current nonce for `owner`. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `owner` - The address for which to return the nonce. + #[must_use] + pub fn nonces(&self, owner: Address) -> U256 { + self.nonces.nonces(owner) + } + + /// Returns the domain separator used in the encoding of the signature for + /// [`Self::permit`], as defined by EIP712. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + #[selector(name = "DOMAIN_SEPARATOR")] + #[must_use] + pub fn domain_separator(&self) -> B256 { + self.eip712.domain_separator_v4() + } + + /// Sets `value` as the allowance of `spender` over `owner`'s tokens, + /// given `owner`'s signed approval. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. given address. + /// * `owner` - Account that owns the tokens. + /// * `spender` - Account that will spend the tokens. + /// * `value` - The number of tokens being permitted to transfer by + /// `spender`. + /// * `deadline` - Deadline for the permit action. + /// * `v` - v value from the `owner`'s signature. + /// * `r` - r value from the `owner`'s signature. + /// * `s` - s value from the `owner`'s signature. + /// + /// # Errors + /// + /// If the `deadline` param is from the past, than the error + /// [`ERC2612ExpiredSignature`] is returned. + /// If signer is not an `owner`, than the error + /// [`ERC2612InvalidSigner`] is returned. + /// * If the `s` value is grater than [`ecdsa::SIGNATURE_S_UPPER_BOUND`], + /// then the error [`ecdsa::Error::InvalidSignatureS`] is returned. + /// * If the recovered address is `Address::ZERO`, then the error + /// [`ecdsa::Error::InvalidSignature`] is returned. + /// If the `spender` address is `Address::ZERO`, then the error + /// [`erc20::Error::InvalidSpender`] is returned. + /// + /// # Events + /// + /// Emits an [`crate::token::erc20::Approval`] event. + /// + /// # Requirements + /// + /// * `spender` cannot be the ``Address::ZERO``. + /// * `deadline` must be a timestamp in the future. + /// * `v`, `r` and `s` must be a valid secp256k1 signature from `owner` + /// over the EIP712-formatted function arguments. + /// * the signature must use `owner`'s current nonce. + #[allow(clippy::too_many_arguments)] + pub fn permit( + &mut self, + owner: Address, + spender: Address, + value: U256, + deadline: U256, + v: u8, + r: B256, + s: B256, + ) -> Result<(), Error> { + if U256::from(block::timestamp()) > deadline { + return Err(ERC2612ExpiredSignature { deadline }.into()); + } + + let struct_hash = keccak256(StructHashTuple::encode_params(&( + *PERMIT_TYPEHASH, + owner, + spender, + value, + self.nonces.use_nonce(owner), + deadline, + ))); + + let hash: B256 = self.eip712.hash_typed_data_v4(struct_hash); + + let signer: Address = ecdsa::recover(self, hash, v, r, s)?; + + if signer != owner { + return Err(ERC2612InvalidSigner { signer, owner }.into()); + } + + self.erc20._approve(owner, spender, value)?; + + Ok(()) + } + + /// Returns the number of tokens in existence. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + pub fn total_supply(&self) -> U256 { + self.erc20.total_supply() + } + + /// Returns the number of tokens owned by `account`. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `account` - Account to get balance from. + pub fn balance_of(&self, account: Address) -> U256 { + self.erc20.balance_of(account) + } + + /// Moves a `value` amount of tokens from the caller's account to `to`. + /// + /// Returns a boolean value indicating whether the operation succeeded. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `to` - Account to transfer tokens to. + /// * `value` - Number of tokens to transfer. + /// + /// # Errors + /// + /// * If the `to` address is `Address::ZERO`, then the error + /// [`crate::token::erc20::Error::InvalidReceiver`] is returned. + /// * If the caller doesn't have a balance of at least `value`, then the + /// error [`crate::token::erc20::Error::InsufficientBalance`] is returned. + /// + /// # Events + /// + /// Emits a [`crate::token::erc20::Transfer`] event. + pub fn transfer( + &mut self, + to: Address, + value: U256, + ) -> Result { + self.erc20.transfer(to, value) + } + + /// Returns the remaining number of tokens that `spender` will be allowed + /// to spend on behalf of `owner` through `transfer_from`. This is zero by + /// default. + /// + /// This value changes when `approve` or `transfer_from` are called. + /// + /// # Arguments + /// + /// * `&self` - Read access to the contract's state. + /// * `owner` - Account that owns the tokens. + /// * `spender` - Account that will spend the tokens. + pub fn allowance(&self, owner: Address, spender: Address) -> U256 { + self.erc20.allowance(owner, spender) + } + + /// Sets a `value` number of tokens as the allowance of `spender` over the + /// caller's tokens. + /// + /// Returns a boolean value indicating whether the operation succeeded. + /// + /// WARNING: Beware that changing an allowance with this method brings the + /// risk that someone may use both the old and the new allowance by + /// unfortunate transaction ordering. One possible solution to mitigate + /// this race condition is to first reduce the `spender`'s allowance to 0 + /// and set the desired value afterwards: + /// + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `owner` - Account that owns the tokens. + /// * `spender` - Account that will spend the tokens. + /// * `value` - The number of tokens being allowed to transfer by `spender`. + /// + /// # Errors + /// + /// If the `spender` address is `Address::ZERO`, then the error + /// [`crate::token::erc20::Error::InvalidSpender`] is returned. + /// + /// # Events + /// + /// Emits an [`crate::token::erc20::Approval`] event. + pub fn approve( + &mut self, + spender: Address, + value: U256, + ) -> Result { + self.erc20.approve(spender, value) + } + + /// Moves a `value` number of tokens from `from` to `to` using the + /// allowance mechanism. `value` is then deducted from the caller's + /// allowance. + /// + /// Returns a boolean value indicating whether the operation succeeded. + /// + /// NOTE: If `value` is the maximum `U256::MAX`, the allowance is not + /// updated on `transfer_from`. This is semantically equivalent to + /// an infinite approval. + /// + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `from` - Account to transfer tokens from. + /// * `to` - Account to transfer tokens to. + /// * `value` - Number of tokens to transfer. + /// + /// # Errors + /// + /// * If the `from` address is `Address::ZERO`, then the error + /// [`crate::token::erc20::Error::InvalidSender`] is returned. + /// * If the `to` address is `Address::ZERO`, then the error + /// [`crate::token::erc20::Error::InvalidReceiver`] is returned. + /// * If not enough allowance is available, then the error + /// [`crate::token::erc20::Error::InsufficientAllowance`] is returned. + /// + /// # Events + /// + /// Emits a [`crate::token::erc20::Transfer`] event. + pub fn transfer_from( + &mut self, + from: Address, + to: Address, + value: U256, + ) -> Result { + self.erc20.transfer_from(from, to, value) + } +} diff --git a/contracts/src/token/erc20/mod.rs b/contracts/src/token/erc20/mod.rs index a1fcd223f..a4d755a41 100644 --- a/contracts/src/token/erc20/mod.rs +++ b/contracts/src/token/erc20/mod.rs @@ -247,15 +247,7 @@ impl IErc20 for Erc20 { value: U256, ) -> Result { let owner = msg::sender(); - if spender.is_zero() { - return Err(Error::InvalidSpender(ERC20InvalidSpender { - spender: Address::ZERO, - })); - } - - self._allowances.setter(owner).insert(spender, value); - evm::log(Approval { owner, spender, value }); - Ok(true) + self._approve(owner, spender, value) } fn transfer_from( @@ -272,6 +264,41 @@ impl IErc20 for Erc20 { } impl Erc20 { + /// Sets a `value` number of tokens as the allowance of `spender` over the + /// caller's tokens. + /// + /// Returns a boolean value indicating whether the operation succeeded. + /// # Arguments + /// + /// * `&mut self` - Write access to the contract's state. + /// * `owner` - Account that owns the tokens. + /// * `spender` - Account that will spend the tokens. + /// + /// # Errors + /// + /// If the `spender` address is `Address::ZERO`, then the error + /// [`Error::InvalidSpender`] is returned. + /// + /// # Events + /// + /// Emits an [`Approval`] event. + fn _approve( + &mut self, + owner: Address, + spender: Address, + value: U256, + ) -> Result { + if spender.is_zero() { + return Err(Error::InvalidSpender(ERC20InvalidSpender { + spender: Address::ZERO, + })); + } + + self._allowances.setter(owner).insert(spender, value); + evm::log(Approval { owner, spender, value }); + Ok(true) + } + /// Internal implementation of transferring tokens between two accounts. /// /// # Arguments diff --git a/contracts/src/utils/cryptography/ecdsa.rs b/contracts/src/utils/cryptography/ecdsa.rs index f90836011..dc8cd5236 100644 --- a/contracts/src/utils/cryptography/ecdsa.rs +++ b/contracts/src/utils/cryptography/ecdsa.rs @@ -81,8 +81,8 @@ sol! { /// /// # Errors /// -/// * If the `s` value is grater than `EIP2_VALUE`, then the error -/// [`Error::InvalidSignatureS`] is returned. +/// * If the `s` value is grater than [`SIGNATURE_S_UPPER_BOUND`], then the +/// error [`Error::InvalidSignatureS`] is returned. /// * If the recovered address is `Address::ZERO`, then the error /// [`Error::InvalidSignature`] is returned. /// diff --git a/contracts/src/utils/cryptography/eip712.rs b/contracts/src/utils/cryptography/eip712.rs index e20b3d7de..2dab05fdf 100644 --- a/contracts/src/utils/cryptography/eip712.rs +++ b/contracts/src/utils/cryptography/eip712.rs @@ -12,10 +12,9 @@ use alloc::{borrow::ToOwned, string::String, vec::Vec}; -use alloy_primitives::{keccak256, Address, FixedBytes, U256}; +use alloy_primitives::{keccak256, Address, B256, U256}; use alloy_sol_types::{sol, SolType}; - -use crate::utils::cryptography::message_hash_utils::to_typed_data_hash; +use stylus_sdk::{block, contract}; /// keccak256("EIP712Domain(string name,string version,uint256 chainId,address /// verifyingContract)") @@ -31,13 +30,37 @@ pub const FIELDS: [u8; 1] = [0x0f]; /// Salt for the domain separator. pub const SALT: [u8; 32] = [0u8; 32]; +/// Prefix for ERC-191 version with `0x01`. +pub const TYPED_DATA_PREFIX: [u8; 2] = [0x19, 0x01]; + /// Tuple for the domain separator. pub type DomainSeparatorTuple = sol! { tuple(bytes32, bytes32, bytes32, uint256, address) }; +/// Returns the keccak256 digest of an EIP-712 typed data (ERC-191 version +/// `0x01`). +/// +/// The digest is calculated from a `domain_separator` and a `struct_hash`, by +/// prefixing them with `[TYPED_DATA_PREFIX]` and hashing the result. It +/// corresponds to the hash signed by the [eth_signTypedData] JSON-RPC method as +/// part of EIP-712. +/// +/// [eth_signTypedData]: https://eips.ethereum.org/EIPS/eip-712 +#[must_use] +pub fn to_typed_data_hash( + domain_separator: &[u8; 32], + struct_hash: &[u8; 32], +) -> B256 { + let mut preimage = [0u8; 66]; + preimage[..2].copy_from_slice(&TYPED_DATA_PREFIX); + preimage[2..34].copy_from_slice(domain_separator); + preimage[34..].copy_from_slice(struct_hash); + keccak256(&preimage) +} + /// EIP-712 Contract interface. -pub trait IEIP712 { +pub trait IEip712 { /// Immutable name of EIP-712 instance. const NAME: &'static str; /// Hashed name of EIP-712 instance. @@ -52,9 +75,14 @@ pub trait IEIP712 { .finalize(); /// Returns chain id. - fn chain_id() -> U256; + fn chain_id() -> U256 { + U256::from(block::chainid()) + } + /// Returns the contract's address. - fn contract_address() -> Address; + fn contract_address() -> Address { + contract::address() + } /// Returns the fields and values that describe the domain separator used by /// this contract for EIP-712 signature. @@ -81,13 +109,13 @@ pub trait IEIP712 { /// # Arguments /// /// * `&self` - Read access to the contract's state. - fn domain_separator_v4(&self) -> FixedBytes<32> { + fn domain_separator_v4(&self) -> B256 { let encoded = DomainSeparatorTuple::encode(&( TYPE_HASH, Self::HASHED_NAME, Self::HASHED_VERSION, Self::chain_id(), - Self::contract_address().into(), + Self::contract_address(), )); keccak256(encoded) @@ -101,10 +129,7 @@ pub trait IEIP712 { /// # Arguments /// /// * `&self` - Read access to the contract's state. - fn hash_typed_data_v4( - &self, - struct_hash: FixedBytes<32>, - ) -> FixedBytes<32> { + fn hash_typed_data_v4(&self, struct_hash: B256) -> B256 { let domain_separator = self.domain_separator_v4(); to_typed_data_hash(&domain_separator, &struct_hash) } @@ -112,9 +137,9 @@ pub trait IEIP712 { #[cfg(all(test, feature = "std"))] mod tests { - use alloy_primitives::{address, uint, Address, U256}; + use alloy_primitives::{address, b256, uint, Address, U256}; - use super::{FIELDS, IEIP712, SALT}; + use super::{to_typed_data_hash, IEip712, FIELDS, SALT}; const CHAIN_ID: U256 = uint!(42161_U256); @@ -124,7 +149,7 @@ mod tests { #[derive(Default)] struct TestEIP712 {} - impl IEIP712 for TestEIP712 { + impl IEip712 for TestEIP712 { const NAME: &'static str = "A Name"; const VERSION: &'static str = "1"; @@ -149,4 +174,24 @@ mod tests { assert_eq!(SALT, domain.5); assert_eq!(Vec::::new(), domain.6); } + + #[test] + fn test_to_typed_data_hash() { + // TYPE_HASH + let domain_separator = b256!( + "8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f" + ); + // bytes32("stylus"); + let struct_hash = b256!( + "7379746c75730000000000000000000000000000000000000000000000000000" + ); + let expected = b256!( + "cefc47137f8165d8270433dd62e395f5672966b83a113a7bb7b2805730a2197e" + ); + + assert_eq!( + expected, + to_typed_data_hash(&domain_separator, &struct_hash), + ); + } } diff --git a/contracts/src/utils/cryptography/message_hash_utils.rs b/contracts/src/utils/cryptography/message_hash_utils.rs deleted file mode 100644 index 70eba1965..000000000 --- a/contracts/src/utils/cryptography/message_hash_utils.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Signature message hash utilities for producing digests to be consumed by -//! `ECDSA` recovery or signing. -//! -//! The library provides methods for generating a hash of a message that -//! conforms to the [EIP 712] specification. -//! -//! [EIP-712]: https://eips.ethereum.org/EIPS/eip-712 - -use alloy_primitives::{keccak256, FixedBytes}; - -/// Prefix for ERC-191 version with `0x01`. -pub const TYPED_DATA_PREFIX: [u8; 2] = [0x19, 0x01]; - -/// Returns the keccak256 digest of an EIP-712 typed data (ERC-191 version -/// `0x01`). -/// -/// The digest is calculated from a `domain_separator` and a `struct_hash`, by -/// prefixing them with `[TYPED_DATA_PREFIX]` and hashing the result. It -/// corresponds to the hash signed by the [eth_signTypedData] JSON-RPC method as -/// part of EIP-712. -/// -/// [eth_signTypedData]: https://eips.ethereum.org/EIPS/eip-712 -#[must_use] -pub fn to_typed_data_hash( - domain_separator: &[u8; 32], - struct_hash: &[u8; 32], -) -> FixedBytes<32> { - let mut preimage = [0u8; 66]; - preimage[..2].copy_from_slice(&TYPED_DATA_PREFIX); - preimage[2..34].copy_from_slice(domain_separator); - preimage[34..].copy_from_slice(struct_hash); - keccak256(preimage) -} - -#[cfg(all(test, feature = "std"))] -mod tests { - use alloy_primitives::b256; - - use super::to_typed_data_hash; - - #[test] - fn test_to_typed_data_hash() { - // TYPE_HASH - let domain_separator = b256!( - "8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f" - ); - // bytes32("stylus"); - let struct_hash = b256!( - "7379746c75730000000000000000000000000000000000000000000000000000" - ); - let expected = b256!( - "cefc47137f8165d8270433dd62e395f5672966b83a113a7bb7b2805730a2197e" - ); - - assert_eq!( - expected, - to_typed_data_hash(&domain_separator, &struct_hash), - ); - } -} diff --git a/contracts/src/utils/cryptography/mod.rs b/contracts/src/utils/cryptography/mod.rs index 0f26f7dcb..24e99ea2a 100644 --- a/contracts/src/utils/cryptography/mod.rs +++ b/contracts/src/utils/cryptography/mod.rs @@ -1,4 +1,3 @@ //! Smart Contracts with cryptography. pub mod ecdsa; pub mod eip712; -pub mod message_hash_utils; diff --git a/contracts/src/utils/nonces.rs b/contracts/src/utils/nonces.rs index 2bce6306c..cf780bbf0 100644 --- a/contracts/src/utils/nonces.rs +++ b/contracts/src/utils/nonces.rs @@ -32,16 +32,19 @@ sol_storage! { #[external] impl Nonces { - /// Returns the unused nonce for the given `account`. + /// Returns the unused nonce for the given account. /// /// # Arguments /// /// * `&self` - Read access to the contract's state. /// * `owner` - The address for which to return the nonce. - fn nonce(&self, owner: Address) -> U256 { + #[must_use] + pub fn nonces(&self, owner: Address) -> U256 { self._nonces.get(owner) } +} +impl Nonces { /// Consumes a nonce for the given `account`. /// /// # Arguments @@ -54,7 +57,7 @@ impl Nonces { /// This function will panic if the nonce for the given `owner` has reached /// the maximum value representable by `U256`, causing the `checked_add` /// method to return `None`. - fn use_nonce(&mut self, owner: Address) -> U256 { + pub fn use_nonce(&mut self, owner: Address) -> U256 { let nonce = self._nonces.get(owner); self._nonces .setter(owner) @@ -114,7 +117,7 @@ mod tests { #[motsu::test] fn initiate_nonce(contract: Nonces) { - assert_eq!(contract.nonce(msg::sender()), U256::ZERO); + assert_eq!(contract.nonces(msg::sender()), U256::ZERO); } #[motsu::test] @@ -124,7 +127,7 @@ mod tests { let use_nonce = contract.use_nonce(owner); assert_eq!(use_nonce, U256::ZERO); - let nonce = contract.nonce(owner); + let nonce = contract.nonces(owner); assert_eq!(nonce, ONE); } @@ -135,7 +138,7 @@ mod tests { let use_checked_nonce = contract.use_checked_nonce(owner, U256::ZERO); assert!(use_checked_nonce.is_ok()); - let nonce = contract.nonce(owner); + let nonce = contract.nonces(owner); assert_eq!(nonce, ONE); } diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index aa5d3cf31..0d9889c0d 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -7,4 +7,3 @@ * xref:access-control.adoc[Access Control] * xref:crypto.adoc[Cryptography] - diff --git a/docs/modules/ROOT/pages/tokens.adoc b/docs/modules/ROOT/pages/tokens.adoc index 87c2a03b6..ece3fb6bb 100644 --- a/docs/modules/ROOT/pages/tokens.adoc +++ b/docs/modules/ROOT/pages/tokens.adoc @@ -26,4 +26,4 @@ Even though the concept of a token is simple, they have a variety of complexitie You've probably heard of the ERC-20 or ERC-721 token standards, and that's why you're here. Head to our specialized guides to learn more about these: - * xref:erc20.adoc[ERC-20]: the most widespread token standard for fungible assets, albeit somewhat limited by its simplicity. \ No newline at end of file + * xref:erc20.adoc[ERC-20]: the most widespread token standard for fungible assets, albeit somewhat limited by its simplicity. diff --git a/examples/erc20-permit/Cargo.toml b/examples/erc20-permit/Cargo.toml new file mode 100644 index 000000000..29cc34249 --- /dev/null +++ b/examples/erc20-permit/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "erc20-permit-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version = "0.0.0" + +[dependencies] +openzeppelin-stylus = { path = "../../contracts" } +alloy-primitives = { workspace = true, features = ["tiny-keccak"] } +stylus-sdk.workspace = true +stylus-proc.workspace = true +mini-alloc.workspace = true + +[dev-dependencies] +alloy.workspace = true +eyre.workspace = true +tokio.workspace = true +e2e = { path = "../../lib/e2e" } + +[features] +e2e = [] + +[lib] +crate-type = ["lib", "cdylib"] diff --git a/examples/erc20-permit/src/constructor.sol b/examples/erc20-permit/src/constructor.sol new file mode 100644 index 000000000..91045cb19 --- /dev/null +++ b/examples/erc20-permit/src/constructor.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +contract Erc20PermitExample { + mapping(address account => uint256) private _balances; + mapping(address account => mapping(address spender => uint256)) + private _allowances; + uint256 private _totalSupply; + mapping(address account => uint256) _nonces; + + constructor() {} +} diff --git a/examples/erc20-permit/src/lib.rs b/examples/erc20-permit/src/lib.rs new file mode 100644 index 000000000..0ecd404b8 --- /dev/null +++ b/examples/erc20-permit/src/lib.rs @@ -0,0 +1,55 @@ +#![cfg_attr(not(test), no_main, no_std)] +extern crate alloc; + +use alloc::vec::Vec; + +use alloy_primitives::{Address, B256, U256}; +use openzeppelin_stylus::{ + token::erc20::extensions::Erc20Permit, utils::cryptography::eip712::IEip712, +}; +use stylus_sdk::prelude::{entrypoint, external, sol_storage}; + +sol_storage! { + #[entrypoint] + struct Erc20PermitExample { + #[borrow] + Erc20Permit erc20_permit; + } + + struct Eip712 {} +} + +impl IEip712 for Eip712 { + const NAME: &'static str = "ERC-20 Permit Example"; + const VERSION: &'static str = "1"; +} + +#[external] +#[inherit(Erc20Permit)] +impl Erc20PermitExample { + // Add token minting feature. + pub fn mint( + &mut self, + account: Address, + value: U256, + ) -> Result<(), Vec> { + self.erc20_permit.erc20._mint(account, value)?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn permit( + &mut self, + owner: Address, + spender: Address, + value: U256, + deadline: U256, + v: u8, + r: B256, + s: B256, + ) -> Result<(), Vec> { + self.erc20_permit + .permit(owner, spender, value, deadline, v, r, s) + .map_err(|e| e.into()) + } +} diff --git a/examples/erc20-permit/tests/abi/mod.rs b/examples/erc20-permit/tests/abi/mod.rs new file mode 100644 index 000000000..0eaccc5de --- /dev/null +++ b/examples/erc20-permit/tests/abi/mod.rs @@ -0,0 +1,35 @@ +#![allow(dead_code)] +#![allow(clippy::too_many_arguments)] +use alloy::sol; + +sol!( + #[sol(rpc)] + contract Erc20Permit { + 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 permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; + function nonces(address owner) external view returns (uint256 nonce); + function DOMAIN_SEPARATOR() external view returns (bytes32 domainSeparator); + + 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 ERC2612ExpiredSignature(uint256 deadline); + error ERC2612InvalidSigner(address signer, address owner); + + #[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-permit/tests/erc20permit.rs b/examples/erc20-permit/tests/erc20permit.rs new file mode 100644 index 000000000..c443afdab --- /dev/null +++ b/examples/erc20-permit/tests/erc20permit.rs @@ -0,0 +1,871 @@ +#![cfg(feature = "e2e")] + +use abi::Erc20Permit; +use alloy::{ + primitives::{b256, keccak256, Address, B256, U256}, + sol, + sol_types::{SolConstructor, SolType}, +}; +use alloy_primitives::uint; +use e2e::{receipt, send, watch, Account, EventExt, Revert}; +use eyre::Result; + +mod abi; + +sol!("src/constructor.sol"); + +// Saturday, 1 January 2000 00:00:00 +const EXPIRED_DEADLINE: U256 = uint!(946_684_800_U256); + +// Wednesday, 1 January 3000 00:00:00 +const FAIR_DEADLINE: U256 = uint!(32_503_680_000_U256); + +// keccak256("Permit(address owner,address spender,uint256 value,uint256 +// nonce,uint256 deadline)") +const PERMIT_TYPEHASH: B256 = + b256!("6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9"); + +type PermitStructHashTuple = sol! { + tuple(bytes32, address, address, uint256, uint256, uint256) +}; + +macro_rules! domain_separator { + ($contract:expr) => {{ + let Erc20Permit::DOMAIN_SEPARATORReturn { domainSeparator } = $contract + .DOMAIN_SEPARATOR() + .call() + .await + .expect("should return `DOMAIN_SEPARATOR`"); + B256::from_slice(domainSeparator.as_slice()) + }}; +} + +async fn deploy(rpc_url: &str, private_key: &str) -> eyre::Result
{ + let args = Erc20PermitExample::constructorCall {}; + let args = alloy::hex::encode(args.abi_encode()); + e2e::deploy(rpc_url, private_key, Some(args)).await +} + +fn to_typed_data_hash(domain_separator: B256, struct_hash: B256) -> B256 { + let typed_dat_hash = + openzeppelin_stylus::utils::cryptography::eip712::to_typed_data_hash( + &domain_separator, + &struct_hash, + ); + + B256::from_slice(typed_dat_hash.as_slice()) +} + +fn permit_struct_hash( + owner: Address, + spender: Address, + value: U256, + nonce: U256, + deadline: U256, +) -> B256 { + keccak256(PermitStructHashTuple::abi_encode(&( + *PERMIT_TYPEHASH, + owner, + spender, + value, + nonce, + deadline, + ))) +} + +// ============================================================================ +// Integration Tests: ERC-20 Permit Extension +// ============================================================================ + +#[e2e::test] +async fn error_when_expired_deadline_for_permit( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let _ = watch!(contract_alice.mint(alice_addr, balance))?; + + let struct_hash = permit_struct_hash( + alice_addr, + bob_addr, + balance, + U256::ZERO, + EXPIRED_DEADLINE, + ); + + let typed_data_hash = + to_typed_data_hash(domain_separator!(contract_alice), struct_hash); + let signature = alice + .sign_hash(&alloy::primitives::B256::from_slice( + typed_data_hash.as_slice(), + )) + .await; + + let err = send!(contract_alice.permit( + alice_addr, + bob_addr, + balance, + EXPIRED_DEADLINE, + signature.v().y_parity_byte_non_eip155().unwrap(), + signature.r().into(), + signature.s().into() + )) + .expect_err("should return `ERC2612ExpiredSignature`"); + assert!(err.reverted_with(Erc20Permit::ERC2612ExpiredSignature { + deadline: EXPIRED_DEADLINE + })); + + Ok(()) +} + +#[e2e::test] +async fn permit_works(alice: Account, bob: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let _ = watch!(contract_alice.mint(alice_addr, balance))?; + + let struct_hash = permit_struct_hash( + alice_addr, + bob_addr, + balance, + U256::ZERO, + FAIR_DEADLINE, + ); + + let typed_data_hash = + to_typed_data_hash(domain_separator!(contract_alice), struct_hash); + let signature = alice + .sign_hash(&alloy::primitives::B256::from_slice( + typed_data_hash.as_slice(), + )) + .await; + + let Erc20Permit::noncesReturn { nonce: initial_nonce } = + contract_alice.nonces(alice_addr).call().await?; + + let Erc20Permit::allowanceReturn { allowance: initial_allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + let receipt = receipt!(contract_alice.permit( + alice_addr, + bob_addr, + balance, + FAIR_DEADLINE, + signature.v().y_parity_byte_non_eip155().unwrap(), + signature.r().into(), + signature.s().into() + ))?; + + assert!(receipt.emits(Erc20Permit::Approval { + owner: alice_addr, + spender: bob_addr, + value: balance, + })); + + let Erc20Permit::allowanceReturn { allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert_eq!(initial_allowance + balance, allowance); + + let Erc20Permit::noncesReturn { nonce } = + contract_alice.nonces(alice_addr).call().await?; + + assert_eq!(initial_nonce + uint!(1_U256), nonce); + + let contract_bob = Erc20Permit::new(contract_addr, &bob.wallet); + let value = balance - uint!(1_U256); + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + + let receipt = + receipt!(contract_bob.transferFrom(alice_addr, bob_addr, value))?; + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::allowanceReturn { allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert!(receipt.emits(Erc20Permit::Transfer { + from: alice_addr, + to: bob_addr, + value + })); + + assert_eq!(initial_alice_balance - value, alice_balance); + assert_eq!(initial_bob_balance + value, bob_balance); + assert_eq!(initial_allowance + balance - value, allowance); + + Ok(()) +} + +#[e2e::test] +async fn permit_rejects_reused_signature( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let _ = watch!(contract_alice.mint(alice_addr, balance))?; + + let struct_hash = permit_struct_hash( + alice_addr, + bob_addr, + balance, + U256::ZERO, + FAIR_DEADLINE, + ); + + let typed_data_hash = + to_typed_data_hash(domain_separator!(contract_alice), struct_hash); + let signature = alice + .sign_hash(&alloy::primitives::B256::from_slice( + typed_data_hash.as_slice(), + )) + .await; + + let _ = watch!(contract_alice.permit( + alice_addr, + bob_addr, + balance, + FAIR_DEADLINE, + signature.v().y_parity_byte_non_eip155().unwrap(), + signature.r().into(), + signature.s().into() + ))?; + + let err = send!(contract_alice.permit( + alice_addr, + bob_addr, + balance, + FAIR_DEADLINE, + signature.v().y_parity_byte_non_eip155().unwrap(), + signature.r().into(), + signature.s().into() + )) + .expect_err("should return `ERC2612InvalidSigner`"); + + let struct_hash = permit_struct_hash( + alice_addr, + bob_addr, + balance, + U256::from(1), + FAIR_DEADLINE, + ); + + let typed_data_hash = + to_typed_data_hash(domain_separator!(contract_alice), struct_hash); + + let recovered = signature + .recover_address_from_prehash(&alloy::primitives::B256::from_slice( + typed_data_hash.as_slice(), + )) + .expect("should recover"); + + assert!(err.reverted_with(Erc20Permit::ERC2612InvalidSigner { + signer: recovered, + owner: alice_addr + })); + + Ok(()) +} + +#[e2e::test] +async fn permit_rejects_invalid_signature( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let _ = watch!(contract_alice.mint(alice_addr, balance))?; + + let struct_hash = permit_struct_hash( + alice_addr, + bob_addr, + balance, + U256::ZERO, + FAIR_DEADLINE, + ); + + let typed_data_hash = + to_typed_data_hash(domain_separator!(contract_alice), struct_hash); + let signature = bob + .sign_hash(&alloy::primitives::B256::from_slice( + typed_data_hash.as_slice(), + )) + .await; + + let err = send!(contract_alice.permit( + alice_addr, + bob_addr, + balance, + FAIR_DEADLINE, + signature.v().y_parity_byte_non_eip155().unwrap(), + signature.r().into(), + signature.s().into() + )) + .expect_err("should return `ERC2612InvalidSigner`"); + + assert!(err.reverted_with(Erc20Permit::ERC2612InvalidSigner { + signer: bob_addr, + owner: alice_addr + })); + + Ok(()) +} + +// ============================================================================ +// Integration Tests: ERC-20 Token +// ============================================================================ + +#[e2e::test] +async fn constructs(alice: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract = Erc20Permit::new(contract_addr, &alice.wallet); + + let Erc20Permit::totalSupplyReturn { totalSupply: total_supply } = + contract.totalSupply().call().await?; + + assert_eq!(total_supply, U256::ZERO); + Ok(()) +} + +#[e2e::test] +async fn mints(alice: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + + let Erc20Permit::balanceOfReturn { balance: initial_balance } = + contract.balanceOf(alice_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract.totalSupply().call().await?; + + assert_eq!(U256::ZERO, initial_balance); + assert_eq!(U256::ZERO, initial_supply); + + let one = uint!(1_U256); + let receipt = receipt!(contract.mint(alice_addr, one))?; + assert!(receipt.emits(Erc20Permit::Transfer { + from: Address::ZERO, + to: alice_addr, + value: one, + })); + + let Erc20Permit::balanceOfReturn { balance } = + contract.balanceOf(alice_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: total_supply } = + contract.totalSupply().call().await?; + + assert_eq!(initial_balance + one, balance); + assert_eq!(initial_supply + one, total_supply); + Ok(()) +} + +#[e2e::test] +async fn mints_rejects_invalid_receiver(alice: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract = Erc20Permit::new(contract_addr, &alice.wallet); + let invalid_receiver = Address::ZERO; + + let Erc20Permit::balanceOfReturn { balance: initial_balance } = + contract.balanceOf(invalid_receiver).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract.totalSupply().call().await?; + + let value = uint!(10_U256); + let err = send!(contract.mint(invalid_receiver, value)) + .expect_err("should not mint tokens for Address::ZERO"); + assert!(err.reverted_with(Erc20Permit::ERC20InvalidReceiver { + receiver: invalid_receiver + })); + + let Erc20Permit::balanceOfReturn { balance } = + contract.balanceOf(invalid_receiver).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: total_supply } = + contract.totalSupply().call().await?; + + assert_eq!(initial_balance, balance); + assert_eq!(initial_supply, total_supply); + Ok(()) +} + +#[e2e::test] +async fn transfers(alice: Account, bob: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let value = uint!(1_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let receipt = receipt!(contract_alice.transfer(bob_addr, value))?; + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + + assert!(receipt.emits(Erc20Permit::Transfer { + from: alice_addr, + to: bob_addr, + value + })); + + assert_eq!(initial_alice_balance - value, alice_balance); + assert_eq!(initial_bob_balance + value, bob_balance); + assert_eq!(initial_supply, supply); + + Ok(()) +} + +#[e2e::test] +async fn transfer_rejects_insufficient_balance( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let value = uint!(11_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let err = send!(contract_alice.transfer(bob_addr, value)) + .expect_err("should not transfer when insufficient balance"); + assert!(err.reverted_with(Erc20Permit::ERC20InsufficientBalance { + sender: alice_addr, + balance, + needed: value + })); + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_bob_balance, bob_balance); + assert_eq!(initial_supply, supply); + + Ok(()) +} + +#[e2e::test] +async fn transfer_rejects_invalid_receiver(alice: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let invalid_receiver = Address::ZERO; + + let balance = uint!(10_U256); + let value = uint!(1_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_receiver_balance } = + contract_alice.balanceOf(invalid_receiver).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let err = send!(contract_alice.transfer(invalid_receiver, value)) + .expect_err("should not transfer to Address::ZERO"); + assert!(err.reverted_with(Erc20Permit::ERC20InvalidReceiver { + receiver: invalid_receiver + })); + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: receiver_balance } = + contract_alice.balanceOf(invalid_receiver).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_receiver_balance, receiver_balance); + assert_eq!(initial_supply, supply); + + Ok(()) +} + +#[e2e::test] +async fn approves(alice: Account, bob: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let one = uint!(1_U256); + let ten = uint!(10_U256); + + let Erc20Permit::allowanceReturn { allowance: initial_alice_bob_allowance } = + contract.allowance(alice_addr, bob_addr).call().await?; + let Erc20Permit::allowanceReturn { allowance: initial_bob_alice_allowance } = + contract.allowance(bob_addr, alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract.totalSupply().call().await?; + + assert_eq!(U256::ZERO, initial_alice_bob_allowance); + assert_eq!(U256::ZERO, initial_bob_alice_allowance); + + let receipt = receipt!(contract.approve(bob_addr, one))?; + assert!(receipt.emits(Erc20Permit::Approval { + owner: alice_addr, + spender: bob_addr, + value: one, + })); + + let Erc20Permit::allowanceReturn { allowance: alice_bob_allowance } = + contract.allowance(alice_addr, bob_addr).call().await?; + let Erc20Permit::allowanceReturn { allowance: bob_alice_allowance } = + contract.allowance(bob_addr, alice_addr).call().await?; + + assert_eq!(initial_alice_bob_allowance + one, alice_bob_allowance); + assert_eq!(initial_bob_alice_allowance, bob_alice_allowance); + + let receipt = receipt!(contract.approve(bob_addr, ten))?; + assert!(receipt.emits(Erc20Permit::Approval { + owner: alice_addr, + spender: bob_addr, + value: ten, + })); + + let Erc20Permit::allowanceReturn { allowance: alice_bob_allowance } = + contract.allowance(alice_addr, bob_addr).call().await?; + let Erc20Permit::allowanceReturn { allowance: bob_alice_allowance } = + contract.allowance(bob_addr, alice_addr).call().await?; + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract.totalSupply().call().await?; + + assert_eq!(initial_alice_bob_allowance + ten, alice_bob_allowance); + assert_eq!(initial_bob_alice_allowance, bob_alice_allowance); + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_bob_balance, bob_balance); + assert_eq!(initial_supply, supply); + + Ok(()) +} + +#[e2e::test] +async fn approve_rejects_invalid_spender(alice: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract = Erc20Permit::new(contract_addr, &alice.wallet); + let alice_addr = alice.address(); + let invalid_spender = Address::ZERO; + + let ten = uint!(10_U256); + + let Erc20Permit::allowanceReturn { + allowance: initial_alice_spender_allowance, + } = contract.allowance(alice_addr, invalid_spender).call().await?; + let Erc20Permit::allowanceReturn { + allowance: initial_spender_alice_allowance, + } = contract.allowance(invalid_spender, alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_spender_balance } = + contract.balanceOf(invalid_spender).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract.totalSupply().call().await?; + + assert_eq!(U256::ZERO, initial_alice_spender_allowance); + assert_eq!(U256::ZERO, initial_spender_alice_allowance); + + let err = send!(contract.approve(invalid_spender, ten)) + .expect_err("should not approve for Address::ZERO"); + + assert!(err.reverted_with(Erc20Permit::ERC20InvalidSpender { + spender: invalid_spender + })); + + let Erc20Permit::allowanceReturn { allowance: alice_spender_allowance } = + contract.allowance(alice_addr, invalid_spender).call().await?; + let Erc20Permit::allowanceReturn { allowance: spender_alice_allowance } = + contract.allowance(invalid_spender, alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: spender_balance } = + contract.balanceOf(invalid_spender).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract.totalSupply().call().await?; + + assert_eq!(initial_alice_spender_allowance, alice_spender_allowance); + assert_eq!(initial_spender_alice_allowance, spender_alice_allowance); + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_spender_balance, spender_balance); + assert_eq!(initial_supply, supply); + + Ok(()) +} + +#[e2e::test] +async fn transfers_from(alice: Account, bob: Account) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let contract_bob = Erc20Permit::new(contract_addr, &bob.wallet); + + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let value = uint!(1_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let _ = watch!(contract_alice.approve(bob_addr, balance))?; + + let Erc20Permit::allowanceReturn { allowance: initial_allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + let receipt = + receipt!(contract_bob.transferFrom(alice_addr, bob_addr, value))?; + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + let Erc20Permit::allowanceReturn { allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert!(receipt.emits(Erc20Permit::Transfer { + from: alice_addr, + to: bob_addr, + value + })); + + assert_eq!(initial_alice_balance - value, alice_balance); + assert_eq!(initial_bob_balance + value, bob_balance); + assert_eq!(initial_supply, supply); + assert_eq!(initial_allowance - value, allowance); + + Ok(()) +} + +#[e2e::test] +async fn transfer_from_reverts_insufficient_balance( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let contract_bob = Erc20Permit::new(contract_addr, &bob.wallet); + + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(1_U256); + let value = uint!(10_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let _ = watch!(contract_alice.approve(bob_addr, value))?; + + let Erc20Permit::allowanceReturn { allowance: initial_allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + let err = send!(contract_bob.transferFrom(alice_addr, bob_addr, value)) + .expect_err("should not transfer when insufficient balance"); + + assert!(err.reverted_with(Erc20Permit::ERC20InsufficientBalance { + sender: alice_addr, + balance, + needed: value + })); + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + let Erc20Permit::allowanceReturn { allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_bob_balance, bob_balance); + assert_eq!(initial_supply, supply); + assert_eq!(initial_allowance, allowance); + + Ok(()) +} + +#[e2e::test] +async fn transfer_from_rejects_insufficient_allowance( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let contract_bob = Erc20Permit::new(contract_addr, &bob.wallet); + + let alice_addr = alice.address(); + let bob_addr = bob.address(); + + let balance = uint!(10_U256); + let value = uint!(1_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let Erc20Permit::allowanceReturn { allowance: initial_allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert_eq!(initial_allowance, U256::ZERO); + + let err = send!(contract_bob.transferFrom(alice_addr, bob_addr, value)) + .expect_err("should not transfer when insufficient allowance"); + + assert!(err.reverted_with(Erc20Permit::ERC20InsufficientAllowance { + spender: bob_addr, + allowance: U256::ZERO, + needed: value + })); + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: bob_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + let Erc20Permit::allowanceReturn { allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_bob_balance, bob_balance); + assert_eq!(initial_supply, supply); + assert_eq!(initial_allowance, allowance); + + Ok(()) +} + +#[e2e::test] +async fn transfer_from_rejects_invalid_receiver( + alice: Account, + bob: Account, +) -> Result<()> { + let contract_addr = deploy(alice.url(), &alice.pk()).await?; + let contract_alice = Erc20Permit::new(contract_addr, &alice.wallet); + let contract_bob = Erc20Permit::new(contract_addr, &bob.wallet); + + let alice_addr = alice.address(); + let bob_addr = bob.address(); + let invalid_receiver = Address::ZERO; + + let balance = uint!(10_U256); + let value = uint!(1_U256); + + let _ = watch!(contract_alice.mint(alice.address(), balance))?; + + let Erc20Permit::balanceOfReturn { balance: initial_alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: initial_receiver_balance } = + contract_alice.balanceOf(invalid_receiver).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: initial_supply } = + contract_alice.totalSupply().call().await?; + + let _ = watch!(contract_alice.approve(bob_addr, balance))?; + + let Erc20Permit::allowanceReturn { allowance: initial_allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + let err = + send!(contract_bob.transferFrom(alice_addr, invalid_receiver, value)) + .expect_err("should not transfer to Address::ZERO"); + + assert!(err.reverted_with(Erc20Permit::ERC20InvalidReceiver { + receiver: invalid_receiver + })); + + let Erc20Permit::balanceOfReturn { balance: alice_balance } = + contract_alice.balanceOf(alice_addr).call().await?; + let Erc20Permit::balanceOfReturn { balance: receiver_balance } = + contract_alice.balanceOf(bob_addr).call().await?; + let Erc20Permit::totalSupplyReturn { totalSupply: supply } = + contract_alice.totalSupply().call().await?; + let Erc20Permit::allowanceReturn { allowance } = + contract_alice.allowance(alice_addr, bob_addr).call().await?; + + assert_eq!(initial_alice_balance, alice_balance); + assert_eq!(initial_receiver_balance, receiver_balance); + assert_eq!(initial_supply, supply); + assert_eq!(initial_allowance, allowance); + + Ok(()) +} diff --git a/examples/erc20/Cargo.toml b/examples/erc20/Cargo.toml index e8242b232..18156804c 100644 --- a/examples/erc20/Cargo.toml +++ b/examples/erc20/Cargo.toml @@ -15,9 +15,9 @@ mini-alloc.workspace = true [dev-dependencies] alloy.workspace = true -e2e = { path = "../../lib/e2e" } -tokio.workspace = true eyre.workspace = true +tokio.workspace = true +e2e = { path = "../../lib/e2e" } [features] e2e = [] diff --git a/examples/erc20/src/constructor.sol b/examples/erc20/src/constructor.sol index 99987731e..96897b8c7 100644 --- a/examples/erc20/src/constructor.sol +++ b/examples/erc20/src/constructor.sol @@ -10,6 +10,7 @@ contract Erc20Example { string private _symbol; uint256 private _cap; bool private _paused; + mapping(address account => uint256) _nonces; error ERC20InvalidCap(uint256 cap); diff --git a/examples/erc20/tests/abi/mod.rs b/examples/erc20/tests/abi/mod.rs index 6360980a9..0e652d268 100644 --- a/examples/erc20/tests/abi/mod.rs +++ b/examples/erc20/tests/abi/mod.rs @@ -1,4 +1,5 @@ #![allow(dead_code)] +#![allow(clippy::too_many_arguments)] use alloy::sol; sol!( diff --git a/lib/motsu/src/shims.rs b/lib/motsu/src/shims.rs index f4460609e..0860a6142 100644 --- a/lib/motsu/src/shims.rs +++ b/lib/motsu/src/shims.rs @@ -365,3 +365,14 @@ pub unsafe extern "C" fn delegate_call_contract( // but the binary does include it. 0 } + +/// Gets a bounded estimate of the Unix timestamp at which the Sequencer +/// sequenced the transaction. See [`Block Numbers and Time`] for more +/// information on how this value is determined. +/// +/// [`Block Numbers and Time`]: https://developer.arbitrum.io/time +#[no_mangle] +pub unsafe extern "C" fn block_timestamp() -> u64 { + // Epoch timestamp: 1st January 2025 00::00::00 + 1_735_689_600 +}