Skip to content

Commit

Permalink
ERC20 Permit Component (#1140)
Browse files Browse the repository at this point in the history
* Add ERC20Permit component

* Add ERC20Permit preset

* Add ERC20Pemit mock and update visibility modifiers

* Add tests for ERC20Permit component

* Add tests for ERC20Permit preset

* Run linter

* Remove ERC20Permit preset

* Add ERC20Permit as a trait of ERC20Component

* Remove ERC20Permit component, restructure files

* Update tests

* Fix test error messages

* Make slight changes to functions doc

* Address review issues

* Update changelog

* Restructure files

* Support fixes in tests

* Fix changelog

* Change signature type to Span

* Support sig changes in tests

* Update in-code doc

* Refactor tests

* Add ERC20Mixin interface, remove ERC20PermitABI interface

* Update doc

* Remove unnecessary constant

* Make StructHashStarknetDomainImpl non-public back
  • Loading branch information
immrsd authored Oct 15, 2024
1 parent f7b7a7b commit 1cf9987
Show file tree
Hide file tree
Showing 14 changed files with 802 additions and 48 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `IUpgradeAndCall` interface (#1148)
- `upgrade_and_call` function in UpgradeableComponent's InternalImpl (#1148)
- `ERC20Permit` impl for `ERC20Component` facilitating token approvals via off-chain signatures (#1140)
- `ISNIP12Metadata` interface for discovering name and version of a SNIP-12 impl (#1140)
- `SNIP12MetadataExternal` impl of `ISNIP12Metadata` interface for `ERC20Component` (#1140)

### Changed

Expand Down
66 changes: 66 additions & 0 deletions packages/test_common/src/mocks/erc20.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,69 @@ pub mod DualCaseERC20VotesMock {
self.erc20.mint(recipient, fixed_supply);
}
}

#[starknet::contract]
pub mod DualCaseERC20PermitMock {
use openzeppelin_token::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<ContractState>;
impl InternalImpl = ERC20Component::InternalImpl<ContractState>;

// IERC20Permit
#[abi(embed_v0)]
impl ERC20PermitImpl = ERC20Component::ERC20PermitImpl<ContractState>;

// ISNIP12Metadata
#[abi(embed_v0)]
impl SNIP12MetadataExternal =
ERC20Component::SNIP12MetadataExternalImpl<ContractState>;

#[storage]
pub struct Storage {
#[substorage(v0)]
pub erc20: ERC20Component::Storage,
#[substorage(v0)]
pub nonces: NoncesComponent::Storage
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
NoncesEvent: NoncesComponent::Event
}

/// Required for hash computation.
pub 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);
}
}
2 changes: 2 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
1 change: 1 addition & 0 deletions packages/token/src/erc20.cairo
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod erc20;
pub mod extensions;
pub mod interface;
pub mod snip12_utils;

pub use erc20::{ERC20Component, ERC20HooksEmptyImpl};
pub use interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait};
122 changes: 113 additions & 9 deletions packages/token/src/erc20/erc20.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
}

//
Expand Down Expand Up @@ -219,7 +226,7 @@ pub mod ERC20Component {
#[embeddable_as(ERC20MixinImpl)]
impl ERC20Mixin<
TContractState, +HasComponent<TContractState>, +ERC20HooksTrait<TContractState>
> of interface::ERC20ABI<ComponentState<TContractState>> {
> of interface::IERC20Mixin<ComponentState<TContractState>> {
// IERC20
fn total_supply(self: @ComponentState<TContractState>) -> u256 {
ERC20::total_supply(self)
Expand Down Expand Up @@ -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<TContractState>,
+ERC20HooksTrait<TContractState>,
impl Nonces: NoncesComponent::HasComponent<TContractState>,
impl Metadata: SNIP12Metadata,
+Drop<TContractState>
> of interface::IERC20Permit<ComponentState<TContractState>> {
/// 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<TContractState>,
owner: ContractAddress,
spender: ContractAddress,
amount: u256,
deadline: u64,
signature: Span<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.into());

// 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<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 +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.
///
Expand Down
32 changes: 2 additions & 30 deletions packages/token/src/erc20/extensions/erc20_votes.cairo
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,16 +14,17 @@ 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::interface::{ISRC6Dispatcher, ISRC6DispatcherTrait};
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};
use starknet::ContractAddress;
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess
};
use super::{Delegation, OffchainMessageHash, SNIP12Metadata};

#[storage]
pub struct Storage {
Expand Down Expand Up @@ -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<Delegation> {
fn hash_struct(self: @Delegation) -> felt252 {
let hash_state = PoseidonTrait::new();
hash_state.update_with(DELEGATION_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,45 @@ pub trait IERC20CamelOnly<TState> {
) -> bool;
}

#[starknet::interface]
pub trait IERC20Mixin<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;

// IERC20Metadata
fn name(self: @TState) -> ByteArray;
fn symbol(self: @TState) -> ByteArray;
fn decimals(self: @TState) -> u8;

// 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;
}

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

#[starknet::interface]
pub trait ERC20ABI<TState> {
// IERC20
Expand All @@ -66,6 +105,21 @@ pub trait ERC20ABI<TState> {
fn transferFrom(
ref self: TState, sender: ContractAddress, recipient: ContractAddress, amount: u256
) -> bool;

// IERC20Permit
fn permit(
ref self: TState,
owner: ContractAddress,
spender: ContractAddress,
amount: u256,
deadline: u64,
signature: Span<felt252>
);
fn nonces(self: @TState, owner: ContractAddress) -> felt252;
fn DOMAIN_SEPARATOR(self: @TState) -> felt252;

// ISNIP12Metadata
fn snip12_metadata(self: @TState) -> (felt252, felt252);
}

#[starknet::interface]
Expand Down
2 changes: 2 additions & 0 deletions packages/token/src/erc20/snip12_utils.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod permit;
pub mod votes;
Loading

0 comments on commit 1cf9987

Please sign in to comment.