Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERC20 Permit Component #1140

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- ERC20Permit component and preset (#1140)

## 0.17.0 (2024-09-23)

### Added
Expand Down
6 changes: 6 additions & 0 deletions packages/testing/src/constants.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'>()
}
Expand Down
117 changes: 111 additions & 6 deletions packages/token/src/erc20/erc20.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@
pub mod ERC20Component {
use core::num::traits::Bounded;
use core::num::traits::Zero;
use crate::erc20::extensions::erc20_permit::Permit;
use crate::erc20::interface;
use starknet::ContractAddress;
use starknet::get_caller_address;
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess
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 {
Expand Down Expand Up @@ -68,6 +74,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';
}

//
Expand Down Expand Up @@ -288,6 +296,104 @@ pub mod ERC20Component {
}
}

/// The ERC20Permit trait 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.
/// The message signed and the signature must follow the SNIP-12 standard for hashing and
/// signing typed structured data.
///
/// 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
///
/// EIP-2612: https://eips.ethereum.org/EIPS/eip-2612
/// SNIP-12: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md
#[embeddable_as(ERC20PermitImpl)]
impl ERC20Permit<
TContractState,
impl ERC20: HasComponent<TContractState>,
+ERC20HooksTrait<TContractState>,
impl Nonces: NoncesComponent::HasComponent<TContractState>,
impl Metadata: SNIP12Metadata,
+Drop<TContractState>
> of interface::IERC20Permit<ComponentState<TContractState>> {
/// Sets the allowance of the `spender` over `owner`'s tokens after validating the signature
/// generated off-chain and signed by the `owner`.
///
/// 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<TContractState>,
owner: ContractAddress,
spender: ContractAddress,
amount: u256,
deadline: u64,
signature: Array<felt252>
) {
// 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
let mut erc20_component = get_dep_component_mut!(ref self, ERC20);
erc20_component._approve(owner, spender, amount);
}

/// Returns the current nonce of the `owner`. A nonce value must be
/// included whenever a signature for `permit` call is generated.
fn nonces(self: @ComponentState<TContractState>, 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<TContractState>) -> 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<TContractState>, impl Metadata: SNIP12Metadata
> of ISNIP12Metadata<ComponentState<TContractState>> {
/// Returns domain name and version used for generating a message hash for permit signature.
fn snip12_metadata(self: @ComponentState<TContractState>) -> (felt252, felt252) {
(Metadata::name(), Metadata::version())
}
}

//
// Internal
//
Expand Down Expand Up @@ -333,7 +439,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.
///
Expand Down
1 change: 1 addition & 0 deletions packages/token/src/erc20/extensions.cairo
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod erc20_permit;
pub mod erc20_votes;

pub use erc20_votes::ERC20VotesComponent;
35 changes: 35 additions & 0 deletions packages/token/src/erc20/extensions/erc20_permit.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts for Cairo v0.17.0 (token/erc20/extensions/erc20_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<Permit> {
fn hash_struct(self: @Permit) -> felt252 {
PoseidonTrait::new().update_with(PERMIT_TYPE_HASH).update_with(*self).finalize()
}
}
54 changes: 54 additions & 0 deletions packages/token/src/erc20/interface.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ pub trait IERC20CamelOnly<TState> {
) -> bool;
}

#[starknet::interface]
pub trait IERC20Permit<TState> {
fn permit(
ref self: TState,
owner: ContractAddress,
spender: ContractAddress,
amount: u256,
deadline: u64,
signature: Array<felt252>
);
fn nonces(self: @TState, owner: ContractAddress) -> felt252;
fn DOMAIN_SEPARATOR(self: @TState) -> felt252;
}

#[starknet::interface]
pub trait ERC20ABI<TState> {
// IERC20
Expand Down Expand Up @@ -107,3 +121,43 @@ pub trait ERC20VotesABI<TState> {
ref self: TState, sender: ContractAddress, recipient: ContractAddress, amount: u256
) -> bool;
}

#[starknet::interface]
pub trait ERC20PermitABI<TState> {
// 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<felt252>
);
fn nonces(self: @TState, owner: ContractAddress) -> felt252;
fn DOMAIN_SEPARATOR(self: @TState) -> felt252;

// ISNIP12Metadata
fn snip12_metadata(self: @TState) -> (felt252, felt252);
}
1 change: 1 addition & 0 deletions packages/token/src/tests/erc20.cairo
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod test_dual20;
mod test_erc20;
mod test_erc20_permit;
mod test_erc20_votes;
Loading