Skip to content

Commit

Permalink
refactor: migrate to standard ERC20 for dynamic supply with separate …
Browse files Browse the repository at this point in the history
…token creation scope (#338)

* refactor: rely on OpenZeppelin for mint/burn

* refactor: separate scopes of create, mint, burn tokens

* refactor: update CreateTokenEvent

* test: integration tests for dynamic supply

* fix: no need to specify minter

* chore: remove mintable component

* fix: disallow mint/burn zero amount

* fix: add initial mint in test_erc20_transfer
  • Loading branch information
Farhad-Shabani authored Mar 4, 2025
1 parent 6751a12 commit 4f58f27
Show file tree
Hide file tree
Showing 19 changed files with 190 additions and 294 deletions.
12 changes: 6 additions & 6 deletions cairo-contracts/packages/apps/src/tests/transfer.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -149,20 +149,20 @@ fn test_mint_ok() {

let token_address = ics20.ibc_token_address(prefixed_denom.key());

let erc20: ERC20Contract = token_address.into();

// Assert the `CreateTokenEvent` emitted.
spy
.assert_create_token_event(
ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, token_address, cfg.amount,
);
spy.assert_create_token_event(ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, token_address);

// Assert if ICS20 performs the mint.
spy.assert_transfer_event(erc20.address, ics20.address, SN_USER(), cfg.amount);

// Assert the `RecvEvent` emitted.
spy
.assert_recv_event(
ics20.address, CS_USER(), SN_USER(), prefixed_denom.clone(), cfg.amount, true,
);

let erc20: ERC20Contract = token_address.into();

// Assert if the transfer happens from the ICS20 address.
spy.assert_transfer_event(erc20.address, ics20.address, SN_USER(), cfg.amount);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ pub mod TokenTransferComponent {
pub decimals: u8,
#[key]
pub address: ContractAddress,
pub initial_supply: u256,
}

// -----------------------------------------------------------
Expand Down Expand Up @@ -714,15 +713,14 @@ pub mod TokenTransferComponent {
amount: u256,
) {
let mut token = self.get_token(denom.key());
if token.is_non_zero() {
token.mint(get_contract_address(), amount);
} else {
if token.is_zero() {
let name = denom.base.hosted().unwrap();

token = self.create_token(name, amount);

self.record_ibc_token(denom, token.address);
}
token.mint(amount);
token.transfer(account, amount);
}

Expand All @@ -737,7 +735,7 @@ pub mod TokenTransferComponent {

token.transfer_from(account, get_contract_address(), amount);

token.burn(get_contract_address(), amount);
token.burn(amount);
}

fn refund_execute(
Expand Down Expand Up @@ -813,14 +811,12 @@ pub mod TokenTransferComponent {
name.clone(),
symbol.clone(),
decimals,
amount.clone(),
get_contract_address(),
get_contract_address(),
);

self.write_salt(salt + 1);

self.emit_create_token_event(name, symbol, decimals, erc20_token.address, amount);
self.emit_create_token_event(name, symbol, decimals, erc20_token.address);

erc20_token
}
Expand Down Expand Up @@ -1015,9 +1011,8 @@ pub mod TokenTransferComponent {
symbol: ByteArray,
decimals: u8,
address: ContractAddress,
initial_supply: u256,
) {
let event = CreateTokenEvent { name, symbol, decimals, address, initial_supply };
let event = CreateTokenEvent { name, symbol, decimals, address };
self.emit(event);
}
}
Expand Down
23 changes: 10 additions & 13 deletions cairo-contracts/packages/apps/src/transfer/erc20_call.cairo
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
use core::num::traits::Zero;
use core::starknet::SyscallResultTrait;
use openzeppelin_token::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait};
use starknet::syscalls::deploy_syscall;
use starknet::syscalls::{call_contract_syscall, deploy_syscall};
use starknet::{ClassHash, ContractAddress, contract_address_const};
use starknet_ibc_utils::mintable::{IERC20MintableDispatcher, IERC20MintableDispatcherTrait};

#[derive(Copy, Debug, Drop, Serde)]
pub struct ERC20Contract {
Expand All @@ -28,23 +27,17 @@ pub impl ERC20ContractImpl of ERC20ContractTrait {
ERC20ABIDispatcher { contract_address: *self.address }
}

fn mintable_dispatcher(self: @ERC20Contract) -> IERC20MintableDispatcher {
IERC20MintableDispatcher { contract_address: *self.address }
}

fn create(
class_hash: ClassHash,
salt: felt252,
name: ByteArray,
symbol: ByteArray,
decimals: u8,
amount: u256,
recipient: ContractAddress,
owner: ContractAddress,
) -> ERC20Contract {
let mut call_data = array![];

(name, symbol, decimals, amount, recipient, owner).serialize(ref call_data);
(name, symbol, decimals, owner).serialize(ref call_data);

let (address, _) = deploy_syscall(class_hash, salt, call_data.span(), false)
.unwrap_syscall();
Expand All @@ -62,12 +55,16 @@ pub impl ERC20ContractImpl of ERC20ContractTrait {
self.dispatcher().transfer_from(sender, recipient, amount)
}

fn mint(self: @ERC20Contract, recipient: ContractAddress, amount: u256) {
self.mintable_dispatcher().permissioned_mint(recipient, amount)
fn mint(self: @ERC20Contract, amount: u256) {
let mut calldata = array![];
amount.serialize(ref calldata);
call_contract_syscall(*self.address, selector!("mint"), calldata.span()).unwrap_syscall();
}

fn burn(self: @ERC20Contract, account: ContractAddress, amount: u256) {
self.mintable_dispatcher().permissioned_burn(account, amount)
fn burn(self: @ERC20Contract, amount: u256) {
let mut calldata = array![];
amount.serialize(ref calldata);
call_contract_syscall(*self.address, selector!("burn"), calldata.span()).unwrap_syscall();
}

fn balance_of(self: @ERC20Contract, from_account: ContractAddress) -> u256 {
Expand Down
36 changes: 20 additions & 16 deletions cairo-contracts/packages/contracts/src/erc20.cairo
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
#[starknet::contract]
pub mod ERC20Mintable {
use core::num::traits::Zero;
use openzeppelin_access::ownable::OwnableComponent;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, interface::IERC20Metadata};
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet_ibc_utils::mintable::ERC20MintableComponent;
use starknet_ibc_utils::mintable::ERC20MintableComponent::ERC20MintableInternalTrait;
use starknet::{ContractAddress, get_caller_address};

component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
component!(path: ERC20MintableComponent, storage: mintable, event: MintableEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);

// Ownable Mixin
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;

// ERC20 Mintable
#[abi(embed_v0)]
impl ERC20MintableImpl = ERC20MintableComponent::ERC20Mintable<ContractState>;

#[abi(embed_v0)]
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
#[abi(embed_v0)]
Expand All @@ -31,8 +25,6 @@ pub mod ERC20Mintable {
#[substorage(v0)]
ownable: OwnableComponent::Storage,
#[substorage(v0)]
mintable: ERC20MintableComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
// The decimals value is stored locally in the contract.
// ref: https://docs.openzeppelin.com/contracts-cairo/0.20.0/erc20#the_storage_approach
Expand All @@ -45,8 +37,6 @@ pub mod ERC20Mintable {
#[flat]
OwnableEvent: OwnableComponent::Event,
#[flat]
MintableEvent: ERC20MintableComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
}

Expand All @@ -56,14 +46,10 @@ pub mod ERC20Mintable {
name: ByteArray,
symbol: ByteArray,
decimals: u8,
initial_supply: u256,
recipient: ContractAddress,
owner: ContractAddress,
) {
self.ownable.initializer(owner);
self.mintable.initializer();
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, initial_supply);

self._set_decimals(decimals);
}
Expand All @@ -84,6 +70,24 @@ pub mod ERC20Mintable {
}
}

#[generate_trait]
#[abi(per_item)]
impl DynamicSupplyImpl of DynamicSupplyTrait {
#[external(v0)]
fn burn(ref self: ContractState, amount: u256) {
self.ownable.assert_only_owner();
assert(amount.is_non_zero(), 'ERC20: burning amount is zero');
self.erc20.burn(get_caller_address(), amount);
}

#[external(v0)]
fn mint(ref self: ContractState, amount: u256) {
self.ownable.assert_only_owner();
assert(amount.is_non_zero(), 'ERC20: minting amount is zero');
self.erc20.mint(get_caller_address(), amount);
}
}

#[generate_trait]
impl InternalImpl of InternalTrait {
fn _set_decimals(ref self: ContractState, decimals: u8) {
Expand Down
1 change: 1 addition & 0 deletions cairo-contracts/packages/contracts/src/lib.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ mod tests {
pub(crate) mod channel;
pub(crate) mod client;
pub(crate) mod connection;
pub(crate) mod erc20;
pub(crate) mod transfer;
}
83 changes: 83 additions & 0 deletions cairo-contracts/packages/contracts/src/tests/erc20.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use snforge_std::{EventSpy, spy_events, start_cheat_caller_address};
use starknet_ibc_apps::transfer::{ERC20Contract, ERC20ContractTrait};
use starknet_ibc_testkit::dummies::{AMOUNT, OWNER, SN_USER, ZERO};
use starknet_ibc_testkit::event_spy::{ERC20EventSpyExt, ERC20EventSpyExtImpl};
use starknet_ibc_testkit::handles::ERC20Handle;
use starknet_ibc_testkit::setup::SetupImpl;

fn setup() -> (ERC20Contract, EventSpy) {
let setup = SetupImpl::default();
let erc20 = SetupImpl::deploy_erc20(@setup, OWNER());
let spy = spy_events();
(erc20, spy)
}

#[test]
fn test_deploy_erc20_ok() {
setup();
}

#[test]
fn test_erc20_mint_ok() {
let (mut erc20, mut spy) = setup();
start_cheat_caller_address(erc20.address, OWNER());
erc20.mint(AMOUNT);
spy.assert_transfer_event(erc20.address, ZERO(), OWNER(), AMOUNT);
erc20.assert_balance(OWNER(), AMOUNT);
erc20.assert_total_supply(AMOUNT);
}

#[test]
fn test_erc20_burn_ok() {
let (mut erc20, mut spy) = setup();
start_cheat_caller_address(erc20.address, OWNER());
erc20.mint(AMOUNT);
erc20.burn(AMOUNT);
spy.assert_transfer_event(erc20.address, OWNER(), ZERO(), AMOUNT);
erc20.assert_balance(OWNER(), 0);
erc20.assert_total_supply(0);
}

#[test]
#[should_panic(expected: 'Caller is not the owner')]
fn test_erc20_unauthorized_mint() {
let (mut erc20, _) = setup();
erc20.mint(AMOUNT);
}

#[test]
#[should_panic(expected: 'Caller is not the owner')]
fn test_erc20_unauthorized_burn() {
let (mut erc20, _) = setup();
start_cheat_caller_address(erc20.address, OWNER());
erc20.mint(AMOUNT);
start_cheat_caller_address(erc20.address, SN_USER());
erc20.burn(AMOUNT);
}

#[test]
#[should_panic(expected: 'ERC20: insufficient allowance')]
fn test_erc20_transfer_without_user_approval() {
let (mut erc20, _) = setup();
start_cheat_caller_address(erc20.address, OWNER());
erc20.mint(AMOUNT);
erc20.transfer_from(OWNER(), SN_USER(), AMOUNT);
erc20.transfer_from(SN_USER(), OWNER(), AMOUNT);
}

#[test]
#[should_panic(expected: 'ERC20: minting amount is zero')]
fn test_erc20_mint_zero_amount() {
let (mut erc20, _) = setup();
start_cheat_caller_address(erc20.address, OWNER());
erc20.mint(0);
}

#[test]
#[should_panic(expected: 'ERC20: burning amount is zero')]
fn test_erc20_burn_zero_amount() {
let (mut erc20, _) = setup();
start_cheat_caller_address(erc20.address, OWNER());
erc20.mint(AMOUNT);
erc20.burn(0);
}
16 changes: 8 additions & 8 deletions cairo-contracts/packages/contracts/src/tests/transfer.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,20 @@ fn test_mint_burn_roundtrip() {
// Fetch the token address.
let token_address = ics20.ibc_token_address(prefixed_denom.key());

let mut erc20: ERC20Contract = token_address.into();

// Assert the `CreateTokenEvent` emitted.
spy
.assert_create_token_event(
ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, token_address, transfer_cfg.amount,
);
spy.assert_create_token_event(ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, token_address);

// Assert if ICS20 performs the mint.
spy.assert_transfer_event(erc20.address, ics20.address, SN_USER(), transfer_cfg.amount);

// Assert the `RecvEvent` emitted.
spy
.assert_recv_event(
ics20.address, CS_USER(), SN_USER(), prefixed_denom.clone(), transfer_cfg.amount, true,
);

let mut erc20: ERC20Contract = token_address.into();

// Assert if the transfer happens from the ICS20 address.
spy.assert_transfer_event(erc20.address, ics20.address, SN_USER(), transfer_cfg.amount);

Expand Down Expand Up @@ -191,7 +191,7 @@ fn test_create_ibc_token_ok() {
let (_, ics20, _, _, _, transfer_cfg, mut spy) = setup(Mode::WithChannel);
let prefixed_denom = transfer_cfg.prefix_hosted_denom();
let address = ics20.create_ibc_token(prefixed_denom.clone());
spy.assert_create_token_event(ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, address, 0);
spy.assert_create_token_event(ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, address);
let queried = ics20.ibc_token_address(prefixed_denom.key());
assert_eq!(address, queried);
}
Expand All @@ -208,7 +208,7 @@ fn test_create_ibc_token_with_multihop() {
let prefixed_denom = transfer_cfg.prefix_hosted_denom();

let address = ics20.create_ibc_token(prefixed_denom.clone());
spy.assert_create_token_event(ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, address, 0);
spy.assert_create_token_event(ics20.address, NAME(), SYMBOL(), DECIMAL_ZERO, address);
let queried = ics20.ibc_token_address(prefixed_denom.key());
assert_eq!(address, queried);
}
Expand Down
4 changes: 4 additions & 0 deletions cairo-contracts/packages/testkit/src/dummies/transfer.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub fn CLASS_HASH() -> ClassHash {
class_hash_const::<'ERC20Mintable'>()
}

pub fn ZERO() -> ContractAddress {
contract_address_const::<0>()
}

pub fn ERC20() -> ERC20Contract {
contract_address_const::<0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7>()
.into()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,9 @@ pub impl TransferEventSpyExtImpl of TransferEventSpyExt {
symbol: ByteArray,
decimals: u8,
address: ContractAddress,
initial_supply: u256,
) {
let expected = Event::CreateTokenEvent(
CreateTokenEvent { name, symbol, decimals, address, initial_supply },
CreateTokenEvent { name, symbol, decimals, address },
);
self.assert_emitted_single(contract_address, expected);
}
Expand Down
Loading

0 comments on commit 4f58f27

Please sign in to comment.