diff --git a/CHANGELOG.md b/CHANGELOG.md index 0312236cf..3b6faeb2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `ISNIP12Metadata` interface to discover name and version of a SNIP-12 impl (#1140) +- `ERC20Permit` impl facilitating token approvals via off-chain signatures (#1140) +- `SNIP12MetadataExternal` impl for `ERC20Component` exposing an external function for reading SNIP-12 metadata (#1140) + ### Changed - Bump scarb to v2.8.3 (#1166) diff --git a/packages/testing/src/constants.cairo b/packages/testing/src/constants.cairo index b005f7b47..04d534dcd 100644 --- a/packages/testing/src/constants.cairo +++ b/packages/testing/src/constants.cairo @@ -8,7 +8,9 @@ pub const SUPPLY: u256 = 2_000; pub const VALUE: u256 = 300; pub const FELT_VALUE: felt252 = 'FELT_VALUE'; pub const ROLE: felt252 = 'ROLE'; +pub const TIMESTAMP: u64 = 1704067200; // 2024-01-01 00:00:00 UTC pub const OTHER_ROLE: felt252 = 'OTHER_ROLE'; +pub const CHAIN_ID: felt252 = 'CHAIN_ID'; pub const TOKEN_ID: u256 = 21; pub const TOKEN_ID_2: u256 = 121; pub const TOKEN_VALUE: u256 = 42; @@ -63,6 +65,10 @@ pub fn CALLER() -> ContractAddress { contract_address_const::<'CALLER'>() } +pub fn CONTRACT_ADDRESS() -> ContractAddress { + contract_address_const::<'CONTRACT_ADDRESS'>() +} + pub fn OWNER() -> ContractAddress { contract_address_const::<'OWNER'>() } diff --git a/packages/token/src/erc20.cairo b/packages/token/src/erc20.cairo index e40fbb2bb..9893fe013 100644 --- a/packages/token/src/erc20.cairo +++ b/packages/token/src/erc20.cairo @@ -2,6 +2,7 @@ pub mod dual20; pub mod erc20; pub mod extensions; pub mod interface; +pub mod snip12_utils; pub use erc20::{ERC20Component, ERC20HooksEmptyImpl}; pub use interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; diff --git a/packages/token/src/erc20/erc20.cairo b/packages/token/src/erc20/erc20.cairo index baf76b0a5..488f2653e 100644 --- a/packages/token/src/erc20/erc20.cairo +++ b/packages/token/src/erc20/erc20.cairo @@ -12,15 +12,20 @@ /// for examples. #[starknet::component] pub mod ERC20Component { - use core::num::traits::Bounded; - use core::num::traits::Zero; + use core::num::traits::{Bounded, Zero}; use crate::erc20::interface; - use starknet::ContractAddress; - use starknet::get_caller_address; - use starknet::storage::{ - Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, - StoragePointerWriteAccess + use crate::erc20::snip12_utils::permit::Permit; + use openzeppelin_account::interface::{ISRC6Dispatcher, ISRC6DispatcherTrait}; + use openzeppelin_utils::cryptography::interface::{INonces, ISNIP12Metadata}; + use openzeppelin_utils::cryptography::snip12::{ + StructHash, OffchainMessageHash, SNIP12Metadata, StarknetDomain }; + use openzeppelin_utils::nonces::NoncesComponent::InternalTrait as NoncesInternalTrait; + use openzeppelin_utils::nonces::NoncesComponent; + use starknet::ContractAddress; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use starknet::{get_block_timestamp, get_caller_address, get_contract_address, get_tx_info}; #[storage] pub struct Storage { @@ -68,6 +73,8 @@ pub mod ERC20Component { pub const MINT_TO_ZERO: felt252 = 'ERC20: mint to 0'; pub const INSUFFICIENT_BALANCE: felt252 = 'ERC20: insufficient balance'; pub const INSUFFICIENT_ALLOWANCE: felt252 = 'ERC20: insufficient allowance'; + pub const EXPIRED_PERMIT_SIGNATURE: felt252 = 'ERC20: expired permit signature'; + pub const INVALID_PERMIT_SIGNATURE: felt252 = 'ERC20: invalid permit signature'; } // @@ -288,6 +295,104 @@ pub mod ERC20Component { } } + /// The ERC20Permit impl implements the EIP-2612 standard, facilitating token approvals via + /// off-chain signatures. This approach allows token holders to delegate their approval to spend + /// tokens without executing an on-chain transaction, reducing gas costs and enhancing + /// usability. + /// See https://eips.ethereum.org/EIPS/eip-2612. + /// + /// The message signed and the signature must follow the SNIP-12 standard for hashing and + /// signing typed structured data. + /// See https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md. + /// + /// To safeguard against replay attacks and ensure the uniqueness of each approval via `permit`, + /// the data signed includes: + /// - The address of the owner + /// - The parameters specified in the `approve` function (spender and amount) + /// - The address of the token contract itself + /// - A nonce, which must be unique for each operation, incrementing after each use to prevent + /// reuse of the signature + /// - The chain ID, which protects against cross-chain replay attacks + #[embeddable_as(ERC20PermitImpl)] + impl ERC20Permit< + TContractState, + +HasComponent, + +ERC20HooksTrait, + impl Nonces: NoncesComponent::HasComponent, + impl Metadata: SNIP12Metadata, + +Drop + > of interface::IERC20Permit> { + /// Sets `amount` as the allowance of `spender` over `owner`'s tokens after validating the + /// signature. + /// + /// Requirements: + /// + /// - `owner` is a deployed account contract. + /// - `spender` is not the zero address. + /// - `deadline` is not a timestamp in the past. + /// - `signature` is a valid signature that can be validated with a call to `owner` account. + /// - `signature` must use the current nonce of the `owner`. + /// + /// Emits an `Approval` event. + fn permit( + ref self: ComponentState, + owner: ContractAddress, + spender: ContractAddress, + amount: u256, + deadline: u64, + signature: Array + ) { + // 1. Ensure the deadline is not missed + assert(get_block_timestamp() <= deadline, Errors::EXPIRED_PERMIT_SIGNATURE); + + // 2. Get the current nonce and increment it + let mut nonces_component = get_dep_component_mut!(ref self, Nonces); + let nonce = nonces_component.use_nonce(owner); + + // 3. Make a call to the account to validate permit signature + let permit = Permit { token: get_contract_address(), spender, amount, nonce, deadline }; + let permit_hash = permit.get_message_hash(owner); + let is_valid_sig_felt = ISRC6Dispatcher { contract_address: owner } + .is_valid_signature(permit_hash, signature); + + // 4. Check the response is either 'VALID' or True (for backwards compatibility) + let is_valid_sig = is_valid_sig_felt == starknet::VALIDATED || is_valid_sig_felt == 1; + assert(is_valid_sig, Errors::INVALID_PERMIT_SIGNATURE); + + // 5. Approve + self._approve(owner, spender, amount); + } + + /// Returns the current nonce of `owner`. A nonce value must be + /// included whenever a signature for `permit` call is generated. + fn nonces(self: @ComponentState, owner: ContractAddress) -> felt252 { + let nonces_component = get_dep_component!(self, Nonces); + nonces_component.nonces(owner) + } + + /// Returns the domain separator used in generating a message hash for `permit` signature. + /// The domain hashing logic follows SNIP-12 standard. + fn DOMAIN_SEPARATOR(self: @ComponentState) -> felt252 { + let domain = StarknetDomain { + name: Metadata::name(), + version: Metadata::version(), + chain_id: get_tx_info().unbox().chain_id, + revision: 1 + }; + domain.hash_struct() + } + } + + #[embeddable_as(SNIP12MetadataExternalImpl)] + impl SNIP12MetadataExternal< + TContractState, +HasComponent, impl Metadata: SNIP12Metadata + > of ISNIP12Metadata> { + /// Returns domain name and version used for generating a message hash for permit signature. + fn snip12_metadata(self: @ComponentState) -> (felt252, felt252) { + (Metadata::name(), Metadata::version()) + } + } + // // Internal // @@ -333,7 +438,6 @@ pub mod ERC20Component { self.update(account, Zero::zero(), amount); } - /// Transfers an `amount` of tokens from `from` to `to`, or alternatively mints (or burns) /// if `from` (or `to`) is the zero address. /// diff --git a/packages/token/src/erc20/extensions/erc20_votes.cairo b/packages/token/src/erc20/extensions/erc20_votes.cairo index a41e6b811..84f6217f1 100644 --- a/packages/token/src/erc20/extensions/erc20_votes.cairo +++ b/packages/token/src/erc20/extensions/erc20_votes.cairo @@ -1,11 +1,6 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Contracts for Cairo v0.17.0 (token/erc20/extensions/erc20_votes.cairo) -use core::hash::{HashStateTrait, HashStateExTrait}; -use core::poseidon::PoseidonTrait; -use openzeppelin_utils::cryptography::snip12::{OffchainMessageHash, StructHash, SNIP12Metadata}; -use starknet::ContractAddress; - /// # ERC20Votes Component /// /// The ERC20Votes component tracks voting units from ERC20 balances, which are a measure of voting @@ -19,8 +14,10 @@ pub mod ERC20VotesComponent { use core::num::traits::Zero; use crate::erc20::ERC20Component; use crate::erc20::interface::IERC20; + use crate::erc20::snip12_utils::votes::Delegation; use openzeppelin_account::dual_account::{DualCaseAccount, DualCaseAccountTrait}; use openzeppelin_governance::utils::interfaces::IVotes; + use openzeppelin_utils::cryptography::snip12::{OffchainMessageHash, SNIP12Metadata}; use openzeppelin_utils::nonces::NoncesComponent::InternalTrait as NoncesInternalTrait; use openzeppelin_utils::nonces::NoncesComponent; use openzeppelin_utils::structs::checkpoint::{Checkpoint, Trace, TraceTrait}; @@ -28,7 +25,6 @@ pub mod ERC20VotesComponent { use starknet::storage::{ Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess }; - use super::{Delegation, OffchainMessageHash, SNIP12Metadata}; #[storage] pub struct Storage { @@ -287,27 +283,3 @@ pub mod ERC20VotesComponent { } } } - -// -// Offchain message hash generation helpers. -// - -// sn_keccak("\"Delegation\"(\"delegatee\":\"ContractAddress\",\"nonce\":\"felt\",\"expiry\":\"u128\")") -// -// Since there's no u64 type in SNIP-12, we use u128 for `expiry` in the type hash generation. -pub const DELEGATION_TYPE_HASH: felt252 = - 0x241244ac7acec849adc6df9848262c651eb035a3add56e7f6c7bcda6649e837; - -#[derive(Copy, Drop, Hash)] -pub struct Delegation { - pub delegatee: ContractAddress, - pub nonce: felt252, - pub expiry: u64 -} - -impl StructHashImpl of StructHash { - fn hash_struct(self: @Delegation) -> felt252 { - let hash_state = PoseidonTrait::new(); - hash_state.update_with(DELEGATION_TYPE_HASH).update_with(*self).finalize() - } -} diff --git a/packages/token/src/erc20/interface.cairo b/packages/token/src/erc20/interface.cairo index 811eb62fc..69b551d19 100644 --- a/packages/token/src/erc20/interface.cairo +++ b/packages/token/src/erc20/interface.cairo @@ -43,6 +43,20 @@ pub trait IERC20CamelOnly { ) -> bool; } +#[starknet::interface] +pub trait IERC20Permit { + fn permit( + ref self: TState, + owner: ContractAddress, + spender: ContractAddress, + amount: u256, + deadline: u64, + signature: Array + ); + fn nonces(self: @TState, owner: ContractAddress) -> felt252; + fn DOMAIN_SEPARATOR(self: @TState) -> felt252; +} + #[starknet::interface] pub trait ERC20ABI { // IERC20 @@ -107,3 +121,43 @@ pub trait ERC20VotesABI { ref self: TState, sender: ContractAddress, recipient: ContractAddress, amount: u256 ) -> bool; } + +#[starknet::interface] +pub trait ERC20PermitABI { + // IERC20 + fn total_supply(self: @TState) -> u256; + fn balance_of(self: @TState, account: ContractAddress) -> u256; + fn allowance(self: @TState, owner: ContractAddress, spender: ContractAddress) -> u256; + fn transfer(ref self: TState, recipient: ContractAddress, amount: u256) -> bool; + fn transfer_from( + ref self: TState, sender: ContractAddress, recipient: ContractAddress, amount: u256 + ) -> bool; + fn approve(ref self: TState, spender: ContractAddress, amount: u256) -> bool; + + // IERC20CamelOnly + fn totalSupply(self: @TState) -> u256; + fn balanceOf(self: @TState, account: ContractAddress) -> u256; + fn transferFrom( + ref self: TState, sender: ContractAddress, recipient: ContractAddress, amount: u256 + ) -> bool; + + // IERC20Metadata + fn name(self: @TState) -> ByteArray; + fn symbol(self: @TState) -> ByteArray; + fn decimals(self: @TState) -> u8; + + // IERC20Permit + fn permit( + ref self: TState, + owner: ContractAddress, + spender: ContractAddress, + amount: u256, + deadline: u64, + signature: Array + ); + fn nonces(self: @TState, owner: ContractAddress) -> felt252; + fn DOMAIN_SEPARATOR(self: @TState) -> felt252; + + // ISNIP12Metadata + fn snip12_metadata(self: @TState) -> (felt252, felt252); +} diff --git a/packages/token/src/erc20/snip12_utils.cairo b/packages/token/src/erc20/snip12_utils.cairo new file mode 100644 index 000000000..9b562ed1d --- /dev/null +++ b/packages/token/src/erc20/snip12_utils.cairo @@ -0,0 +1,2 @@ +pub mod permit; +pub mod votes; diff --git a/packages/token/src/erc20/snip12_utils/permit.cairo b/packages/token/src/erc20/snip12_utils/permit.cairo new file mode 100644 index 000000000..e39341307 --- /dev/null +++ b/packages/token/src/erc20/snip12_utils/permit.cairo @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.17.0 (token/erc20/snip12_utils/permit.cairo) + +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::poseidon::PoseidonTrait; +use openzeppelin_utils::cryptography::snip12::StructHash; +use starknet::ContractAddress; + +#[derive(Copy, Drop, Hash)] +pub struct Permit { + pub token: ContractAddress, + pub spender: ContractAddress, + pub amount: u256, + pub nonce: felt252, + pub deadline: u64, +} + +// Since there's no u64 type in SNIP-12, the type used for `deadline` parameter is u128 +// selector!( +// "\"Permit\"( +// \"token\":\"ContractAddress\", +// \"spender\":\"ContractAddress\", +// \"amount\":\"u256\", +// \"nonce\":\"felt\", +// \"deadline\":\"u128\" +// )" +// ); +pub const PERMIT_TYPE_HASH: felt252 = + 0x2a8eb238e7cde741a544afcc79fe945d4292b089875fd068633854927fd5a96; + +impl StructHashImpl of StructHash { + fn hash_struct(self: @Permit) -> felt252 { + PoseidonTrait::new().update_with(PERMIT_TYPE_HASH).update_with(*self).finalize() + } +} diff --git a/packages/token/src/erc20/snip12_utils/votes.cairo b/packages/token/src/erc20/snip12_utils/votes.cairo new file mode 100644 index 000000000..f611bdaaa --- /dev/null +++ b/packages/token/src/erc20/snip12_utils/votes.cairo @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.17.0 (token/erc20/snip12_utils/votes.cairo) + +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::poseidon::PoseidonTrait; +use openzeppelin_utils::cryptography::snip12::StructHash; +use starknet::ContractAddress; + +// sn_keccak("\"Delegation\"(\"delegatee\":\"ContractAddress\",\"nonce\":\"felt\",\"expiry\":\"u128\")") +// +// Since there's no u64 type in SNIP-12, we use u128 for `expiry` in the type hash generation. +pub const DELEGATION_TYPE_HASH: felt252 = + 0x241244ac7acec849adc6df9848262c651eb035a3add56e7f6c7bcda6649e837; + +#[derive(Copy, Drop, Hash)] +pub struct Delegation { + pub delegatee: ContractAddress, + pub nonce: felt252, + pub expiry: u64 +} + +impl StructHashImpl of StructHash { + fn hash_struct(self: @Delegation) -> felt252 { + PoseidonTrait::new().update_with(DELEGATION_TYPE_HASH).update_with(*self).finalize() + } +} diff --git a/packages/token/src/tests/erc20.cairo b/packages/token/src/tests/erc20.cairo index 213861a92..aa7d68fa8 100644 --- a/packages/token/src/tests/erc20.cairo +++ b/packages/token/src/tests/erc20.cairo @@ -1,3 +1,4 @@ mod test_dual20; mod test_erc20; +mod test_erc20_permit; mod test_erc20_votes; diff --git a/packages/token/src/tests/erc20/test_erc20_permit.cairo b/packages/token/src/tests/erc20/test_erc20_permit.cairo new file mode 100644 index 000000000..8cda5273f --- /dev/null +++ b/packages/token/src/tests/erc20/test_erc20_permit.cairo @@ -0,0 +1,487 @@ +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::poseidon::PoseidonTrait; +use crate::erc20::ERC20Component::{ERC20MixinImpl, InternalImpl}; +use crate::erc20::interface::{ERC20PermitABIDispatcher, ERC20PermitABIDispatcherTrait}; +use crate::erc20::snip12_utils::permit::{Permit, PERMIT_TYPE_HASH}; +use crate::tests::mocks::erc20_permit_mocks::DualCaseERC20PermitMock; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants; +use openzeppelin_testing::signing::{StarkKeyPair, StarkSerializedSigning}; +use openzeppelin_utils::cryptography::snip12::{StarknetDomain, StructHashStarknetDomainImpl}; +use openzeppelin_utils::serde::SerializedAppend; +use snforge_std::signature::stark_curve::StarkCurveSignerImpl; +use snforge_std::{ + start_cheat_caller_address, start_cheat_block_timestamp, start_cheat_chain_id_global +}; +use starknet::ContractAddress; + +// +// Constants +// + +#[derive(Copy, Drop)] +struct TestData { + contract_address: ContractAddress, + owner: ContractAddress, + key_pair: StarkKeyPair, + spender: ContractAddress, + amount: u256, + deadline: u64, + token_supply: u256, + name: @ByteArray, + symbol: @ByteArray, + metadata_name: felt252, + metadata_version: felt252, + chain_id: felt252, + revision: felt252 +} + +fn TEST_DATA() -> TestData { + TestData { + contract_address: constants::CONTRACT_ADDRESS(), + owner: constants::OWNER(), + key_pair: constants::stark::KEY_PAIR(), + spender: constants::SPENDER(), + amount: constants::TOKEN_VALUE, + deadline: constants::TIMESTAMP, + token_supply: constants::SUPPLY, + name: @constants::NAME(), + symbol: @constants::SYMBOL(), + metadata_name: DualCaseERC20PermitMock::SNIP12MetadataImpl::name(), + metadata_version: DualCaseERC20PermitMock::SNIP12MetadataImpl::version(), + chain_id: constants::CHAIN_ID, + revision: 1 // As in the current SNIP-12 implementation + } +} + +// +// Setup +// + +fn setup(data: TestData) -> ERC20PermitABIDispatcher { + start_cheat_chain_id_global(data.chain_id); + + utils::declare_and_deploy_at( + "DualCaseAccountMock", data.owner, array![data.key_pair.public_key] + ); + + let mut calldata = array![]; + calldata.append_serde(data.name.clone()); + calldata.append_serde(data.symbol.clone()); + calldata.append_serde(data.token_supply); + calldata.append_serde(data.owner); + utils::declare_and_deploy_at("DualCaseERC20PermitMock", data.contract_address, calldata); + + ERC20PermitABIDispatcher { contract_address: data.contract_address } +} + +// +// IERC20Permit +// + +#[test] +fn test_valid_permit_default_data() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + assert_valid_allowance(owner, spender, 0); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + mock.permit(owner, spender, amount, deadline, signature); + + assert_valid_allowance(owner, spender, amount); + assert_valid_nonce(owner, nonce + 1); +} + +#[test] +fn test_valid_permit_other_data() { + let mut data = TEST_DATA(); + data.spender = constants::OTHER(); + data.amount = constants::TOKEN_VALUE_2; + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + assert_valid_allowance(owner, spender, 0); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + mock.permit(owner, spender, amount, deadline, signature); + + assert_valid_allowance(owner, spender, amount); + assert_valid_nonce(owner, nonce + 1); +} + +#[test] +fn test_spend_permit() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + start_cheat_caller_address(mock.contract_address, spender); + + mock.permit(owner, spender, amount, deadline, signature); + mock.transfer_from(owner, spender, amount); + + assert_valid_balance(spender, amount); + assert_valid_balance(owner, data.token_supply - amount); + assert_valid_allowance(owner, spender, 0); + assert_valid_nonce(owner, nonce + 1); +} + +#[test] +fn test_spend_half_permit() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + start_cheat_caller_address(mock.contract_address, spender); + + mock.permit(owner, spender, amount, deadline, signature); + let transfer_amount = amount / 2; + mock.transfer_from(owner, spender, transfer_amount); + + assert_valid_balance(spender, transfer_amount); + assert_valid_balance(owner, data.token_supply - transfer_amount); + assert_valid_allowance(owner, spender, amount - transfer_amount); + assert_valid_nonce(owner, nonce + 1); +} + +#[test] +fn test_subsequent_permits() { + let mut data = TEST_DATA(); + let (owner, spender, amount_1, deadline) = ( + data.owner, data.spender, data.amount, data.deadline + ); + let mock = setup(data); + + let mut expected_owner_balance = data.token_supply; + let mut expected_spender_balance = 0; + start_cheat_caller_address(mock.contract_address, spender); + + // Permit 1 + let nonce_1 = mock.nonces(owner); + let signature_1 = prepare_permit_signature(data, nonce_1); + + mock.permit(owner, spender, amount_1, deadline, signature_1); + mock.transfer_from(owner, spender, amount_1); + + expected_owner_balance -= amount_1; + expected_spender_balance += amount_1; + assert_valid_balance(owner, expected_owner_balance); + assert_valid_balance(spender, expected_spender_balance); + assert_valid_allowance(owner, spender, 0); + assert_valid_nonce(owner, nonce_1 + 1); + + // Permit 2 + data.amount = constants::TOKEN_VALUE_2; + let amount_2 = data.amount; + let nonce_2 = mock.nonces(owner); + let signature_2 = prepare_permit_signature(data, nonce_2); + + mock.permit(owner, spender, amount_2, deadline, signature_2); + mock.transfer_from(owner, spender, amount_2); + + expected_owner_balance -= amount_2; + expected_spender_balance += amount_2; + assert_valid_balance(owner, expected_owner_balance); + assert_valid_balance(spender, expected_spender_balance); + assert_valid_allowance(owner, spender, 0); + assert_valid_nonce(owner, nonce_2 + 1); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_replay_attack() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let nonce = mock.nonces(owner); + start_cheat_caller_address(mock.contract_address, spender); + + // 1st call is fine + let signature = prepare_permit_signature(data, nonce); + mock.permit(owner, spender, amount, deadline, signature); + + // 2nd call must fail (nonce already used) + let signature = prepare_permit_signature(data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +fn test_domain_separator() { + let data = TEST_DATA(); + let mock = setup(data); + + let sn_domain = StarknetDomain { + name: data.metadata_name, + version: data.metadata_version, + chain_id: data.chain_id, + revision: data.revision + }; + let expected_domain_separator = sn_domain.hash_struct(); + assert_eq!(mock.DOMAIN_SEPARATOR(), expected_domain_separator); +} + +// +// SNIP12Metadata +// + +#[test] +fn test_permit_type_hash() { + let expected_type_hash = selector!( + "\"Permit\"(\"token\":\"ContractAddress\",\"spender\":\"ContractAddress\",\"amount\":\"u256\",\"nonce\":\"felt\",\"deadline\":\"u128\")" + ); + assert_eq!(PERMIT_TYPE_HASH, expected_type_hash); +} + +#[test] +fn test_snip12_metadata() { + let data = TEST_DATA(); + let mock = setup(data); + + let (metadata_name, metadata_version) = mock.snip12_metadata(); + assert_eq!(metadata_name, data.metadata_name); + assert_eq!(metadata_version, data.metadata_version); +} + +// +// Invalid signature +// + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_owner() { + let data = TEST_DATA(); + let (spender, amount, deadline) = (data.spender, data.amount, data.deadline); + let mock = setup(data); + + let another_account = constants::OTHER(); + utils::deploy_another_at(data.owner, another_account, array![data.key_pair.public_key]); + let nonce = mock.nonces(another_account); + let signature = prepare_permit_signature(data, nonce); + mock.permit(another_account, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_token_address() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + mock.permit(owner, spender, amount, deadline, signature); + + let mut modified_data = data; + modified_data.contract_address = constants::OTHER(); + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_spender() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.spender = constants::OTHER(); + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_amount() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.amount = constants::TOKEN_VALUE_2; + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_nonce() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let another_nonce = mock.nonces(owner) + 1; + let signature = prepare_permit_signature(data, another_nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_sig_r() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + let (sig_r, sig_s) = (*signature.at(0), *signature.at(1)); + let modified_signature = array![sig_r + 1, sig_s]; + mock.permit(owner, spender, amount, deadline, modified_signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_sig_s() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + let (sig_r, sig_s) = (*signature.at(0), *signature.at(1)); + let modified_signature = array![sig_r, sig_s + 1]; + mock.permit(owner, spender, amount, deadline, modified_signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_metadata_name() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.metadata_name = 'ANOTHER_NAME'; + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_metadata_version() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.metadata_version = 'ANOTHER_VERSION'; + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_signing_key() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.key_pair = constants::stark::KEY_PAIR_2(); + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_chain_id() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.chain_id = 'ANOTHER_CHAIN_ID'; + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +#[test] +#[should_panic(expected: 'ERC20: invalid permit signature')] +fn test_invalid_sig_bad_revision() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let mut modified_data = data; + modified_data.revision = 'ANOTHER_REVISION'; + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(modified_data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +// +// Expired signature +// + +#[test] +#[should_panic(expected: 'ERC20: expired permit signature')] +fn test_invalid_sig_bad_deadline() { + let data = TEST_DATA(); + let (owner, spender, amount, deadline) = (data.owner, data.spender, data.amount, data.deadline); + let mock = setup(data); + + let timestamp_after_deadline = deadline + 1; + start_cheat_block_timestamp(mock.contract_address, timestamp_after_deadline); + let nonce = mock.nonces(owner); + let signature = prepare_permit_signature(data, nonce); + mock.permit(owner, spender, amount, deadline, signature); +} + +// +// Helpers +// + +fn prepare_permit_signature(data: TestData, nonce: felt252) -> Array { + let sn_domain = StarknetDomain { + name: data.metadata_name, + version: data.metadata_version, + chain_id: data.chain_id, + revision: data.revision + }; + let permit = Permit { + token: data.contract_address, + spender: data.spender, + amount: data.amount, + nonce, + deadline: data.deadline + }; + let msg_hash = PoseidonTrait::new() + .update_with('StarkNet Message') + .update_with(sn_domain.hash_struct()) + .update_with(data.owner) + .update_with(permit.hash_struct()) + .finalize(); + + data.key_pair.serialized_sign(msg_hash) +} + +fn assert_valid_nonce(account: ContractAddress, expected: felt252) { + let mock = ERC20PermitABIDispatcher { contract_address: constants::CONTRACT_ADDRESS() }; + assert_eq!(mock.nonces(account), expected); +} + +fn assert_valid_allowance(owner: ContractAddress, spender: ContractAddress, expected: u256) { + let mock = ERC20PermitABIDispatcher { contract_address: constants::CONTRACT_ADDRESS() }; + assert_eq!(mock.allowance(owner, spender), expected); +} + +fn assert_valid_balance(account: ContractAddress, expected: u256) { + let mock = ERC20PermitABIDispatcher { contract_address: constants::CONTRACT_ADDRESS() }; + assert_eq!(mock.balance_of(account), expected); +} diff --git a/packages/token/src/tests/erc20/test_erc20_votes.cairo b/packages/token/src/tests/erc20/test_erc20_votes.cairo index dbff4693e..2203fc8a8 100644 --- a/packages/token/src/tests/erc20/test_erc20_votes.cairo +++ b/packages/token/src/tests/erc20/test_erc20_votes.cairo @@ -4,7 +4,7 @@ use crate::erc20::ERC20Component::InternalImpl as ERC20Impl; use crate::erc20::extensions::ERC20VotesComponent::{DelegateChanged, DelegateVotesChanged}; use crate::erc20::extensions::ERC20VotesComponent::{ERC20VotesImpl, InternalImpl}; use crate::erc20::extensions::ERC20VotesComponent; -use crate::erc20::extensions::erc20_votes::Delegation; +use crate::erc20::snip12_utils::votes::Delegation; use crate::tests::mocks::erc20_votes_mocks::DualCaseERC20VotesMock::SNIP12MetadataImpl; use crate::tests::mocks::erc20_votes_mocks::DualCaseERC20VotesMock; use openzeppelin_testing as utils; @@ -52,7 +52,7 @@ fn setup_account(public_key: felt252) -> ContractAddress { // Checkpoints unordered insertion #[test] -#[should_panic(expected: ('Unordered insertion',))] +#[should_panic(expected: 'Unordered insertion')] fn test__delegate_checkpoints_unordered_insertion() { let mut state = setup(); let mut trace = state.ERC20Votes_delegate_checkpoints.read(OWNER()); @@ -63,7 +63,7 @@ fn test__delegate_checkpoints_unordered_insertion() { } #[test] -#[should_panic(expected: ('Unordered insertion',))] +#[should_panic(expected: 'Unordered insertion')] fn test__total_checkpoints_unordered_insertion() { let mut state = setup(); let mut trace = state.ERC20Votes_total_checkpoints.read(); @@ -111,7 +111,7 @@ fn test_get_past_votes() { } #[test] -#[should_panic(expected: ('Votes: future Lookup',))] +#[should_panic(expected: 'Votes: future Lookup')] fn test_get_past_votes_future_lookup() { let state = setup(); @@ -145,7 +145,7 @@ fn test_get_past_total_supply() { } #[test] -#[should_panic(expected: ('Votes: future Lookup',))] +#[should_panic(expected: 'Votes: future Lookup')] fn test_get_past_total_supply_future_lookup() { let state = setup(); @@ -264,7 +264,7 @@ fn test_delegate_by_sig() { } #[test] -#[should_panic(expected: ('Votes: expired signature',))] +#[should_panic(expected: 'Votes: expired signature')] fn test_delegate_by_sig_past_expiry() { start_cheat_block_timestamp_global('ts5'); @@ -276,7 +276,7 @@ fn test_delegate_by_sig_past_expiry() { } #[test] -#[should_panic(expected: ('Nonces: invalid nonce',))] +#[should_panic(expected: 'Nonces: invalid nonce')] fn test_delegate_by_sig_invalid_nonce() { let mut state = setup(); let signature = array![0, 0]; @@ -285,7 +285,7 @@ fn test_delegate_by_sig_invalid_nonce() { } #[test] -#[should_panic(expected: ('Votes: invalid signature',))] +#[should_panic(expected: 'Votes: invalid signature')] fn test_delegate_by_sig_invalid_signature() { let mut state = setup(); let account = setup_account(0x123); @@ -332,7 +332,7 @@ fn test_checkpoints() { } #[test] -#[should_panic(expected: ('Array overflow',))] +#[should_panic(expected: 'Array overflow')] fn test__checkpoints_array_overflow() { let state = setup(); state.checkpoints(OWNER(), 1); diff --git a/packages/token/src/tests/mocks.cairo b/packages/token/src/tests/mocks.cairo index 9574a4b7a..22f2aa689 100644 --- a/packages/token/src/tests/mocks.cairo +++ b/packages/token/src/tests/mocks.cairo @@ -2,6 +2,7 @@ pub(crate) mod account_mocks; pub(crate) mod erc1155_mocks; pub(crate) mod erc1155_receiver_mocks; pub(crate) mod erc20_mocks; +pub(crate) mod erc20_permit_mocks; pub(crate) mod erc20_votes_mocks; pub(crate) mod erc2981_mocks; pub(crate) mod erc721_enumerable_mocks; diff --git a/packages/token/src/tests/mocks/erc20_permit_mocks.cairo b/packages/token/src/tests/mocks/erc20_permit_mocks.cairo new file mode 100644 index 000000000..8c3b0e48b --- /dev/null +++ b/packages/token/src/tests/mocks/erc20_permit_mocks.cairo @@ -0,0 +1,65 @@ +#[starknet::contract] +pub(crate) mod DualCaseERC20PermitMock { + use crate::erc20::{ERC20Component, ERC20HooksEmptyImpl}; + use openzeppelin_utils::cryptography::nonces::NoncesComponent; + use openzeppelin_utils::cryptography::snip12::SNIP12Metadata; + use starknet::ContractAddress; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + component!(path: NoncesComponent, storage: nonces, event: NoncesEvent); + + // ERC20Mixin + #[abi(embed_v0)] + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl; + impl InternalImpl = ERC20Component::InternalImpl; + + // IERC20Permit + #[abi(embed_v0)] + impl ERC20PermitImpl = ERC20Component::ERC20PermitImpl; + + // ISNIP12Metadata + #[abi(embed_v0)] + impl SNIP12MetadataExternal = + ERC20Component::SNIP12MetadataExternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + erc20: ERC20Component::Storage, + #[substorage(v0)] + nonces: NoncesComponent::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event, + #[flat] + NoncesEvent: NoncesComponent::Event + } + + /// Required for hash computation. + pub(crate) impl SNIP12MetadataImpl of SNIP12Metadata { + fn name() -> felt252 { + 'DAPP_NAME' + } + fn version() -> felt252 { + 'DAPP_VERSION' + } + } + + /// Sets the token `name` and `symbol`. + /// Mints `fixed_supply` tokens to `recipient`. + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + initial_supply: u256, + recipient: ContractAddress + ) { + self.erc20.initializer(name, symbol); + self.erc20.mint(recipient, initial_supply); + } +} diff --git a/packages/utils/src/cryptography/interface.cairo b/packages/utils/src/cryptography/interface.cairo index cb7c314f6..5b8043a25 100644 --- a/packages/utils/src/cryptography/interface.cairo +++ b/packages/utils/src/cryptography/interface.cairo @@ -7,3 +7,8 @@ use starknet::ContractAddress; pub trait INonces { fn nonces(self: @TState, owner: ContractAddress) -> felt252; } + +#[starknet::interface] +pub trait ISNIP12Metadata { + fn snip12_metadata(self: @TState) -> (felt252, felt252); +} diff --git a/packages/utils/src/cryptography/snip12.cairo b/packages/utils/src/cryptography/snip12.cairo index 39f460fa3..ba8aa4241 100644 --- a/packages/utils/src/cryptography/snip12.cairo +++ b/packages/utils/src/cryptography/snip12.cairo @@ -32,7 +32,7 @@ pub trait OffchainMessageHash { fn get_message_hash(self: @T, signer: ContractAddress) -> felt252; } -impl StructHashStarknetDomainImpl of StructHash { +pub impl StructHashStarknetDomainImpl of StructHash { fn hash_struct(self: @StarknetDomain) -> felt252 { let hash_state = PoseidonTrait::new(); hash_state.update_with(STARKNET_DOMAIN_TYPE_HASH).update_with(*self).finalize()