diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..fbe37ca8 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +CONTRACT_SRC=${CONTRACT_SRC:-$(pwd)/contracts/target/dev/starknet_ibc_Transfer.contract_class.json} +RPC_URL=https://starknet-sepolia.public.blastapi.io/rpc/v0_7 +ACCOUNT_SRC="${HOME}/.starkli-wallets/deployer/account.json" +KEYSTORE_SRC="${HOME}/.starkli-wallets/deployer/keystore.json" +KEYSTORE_PASS= + +ERC20_CLASS_HASH="" +ICS20_CLASS_HASH="" +CONTRACT_ADDRESS="" \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..e524a1fa --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests +on: + pull_request: + paths: + - .github/workflows/tests.yaml + - contracts/** + - justfile + + push: + tags: + - v[0-9]+.* + branches: + - "release/*" + - main + +jobs: + test-contracts: + name: Test Cairo Contracts + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - name: Install Scarb + uses: software-mansion/setup-scarb@v1 + with: + scarb-version: "2.6.5" + - name: Install Just + uses: extractions/setup-just@v1 + - name: Run Tests + run: just test-contracts diff --git a/.gitignore b/.gitignore index e91ae694..1f9d1131 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ corelib/ target/ **/Cargo.lock +.env + # vscode .vscode/ diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml index 082ec5ee..d9bed03b 100644 --- a/contracts/Scarb.toml +++ b/contracts/Scarb.toml @@ -19,6 +19,9 @@ snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry", tag = " [lib] [[target.starknet-contract]] +allowed-libfuncs-list.name = "experimental" +sierra = true +casm = false [tool.fmt] sort-module-level-items = true diff --git a/contracts/src/apps.cairo b/contracts/src/apps.cairo index 014e52f2..ed5e643a 100644 --- a/contracts/src/apps.cairo +++ b/contracts/src/apps.cairo @@ -1 +1,4 @@ +pub mod governance; +pub mod mintable; pub mod transfer; +pub mod transferrable; diff --git a/contracts/src/apps/governance.cairo b/contracts/src/apps/governance.cairo new file mode 100644 index 00000000..fee8901c --- /dev/null +++ b/contracts/src/apps/governance.cairo @@ -0,0 +1,2 @@ +pub mod component; +pub mod interface; diff --git a/contracts/src/apps/governance/component.cairo b/contracts/src/apps/governance/component.cairo new file mode 100644 index 00000000..7fcfaef8 --- /dev/null +++ b/contracts/src/apps/governance/component.cairo @@ -0,0 +1,30 @@ +#[starknet::component] +pub mod IBCGovernanceComponent { + use starknet::ContractAddress; + use starknet::get_caller_address; + use starknet_ibc::apps::governance::interface::IGovernance; + + #[storage] + struct Storage { + governor: ContractAddress, + } + + #[event] + #[derive(Drop, Debug, starknet::Event)] + pub enum Event {} + + #[embeddable_as(Governance)] + pub impl GovernanceImpl< + TContractState, +HasComponent, +Drop + > of IGovernance> {} + + #[generate_trait] + pub impl GovernanceInternalImpl< + TContractState, +HasComponent, +Drop + > of GovernanceInternalTrait { + fn initializer(ref self: ComponentState) { + self.governor.write(get_caller_address()); + } + } +} + diff --git a/contracts/src/apps/governance/interface.cairo b/contracts/src/apps/governance/interface.cairo new file mode 100644 index 00000000..95ce4d2a --- /dev/null +++ b/contracts/src/apps/governance/interface.cairo @@ -0,0 +1,3 @@ +#[starknet::interface] +pub trait IGovernance {} + diff --git a/contracts/src/apps/mintable.cairo b/contracts/src/apps/mintable.cairo new file mode 100644 index 00000000..3e1ae43c --- /dev/null +++ b/contracts/src/apps/mintable.cairo @@ -0,0 +1,3 @@ +pub mod component; +pub mod errors; +pub mod interface; diff --git a/contracts/src/apps/mintable/component.cairo b/contracts/src/apps/mintable/component.cairo new file mode 100644 index 00000000..04af5ba0 --- /dev/null +++ b/contracts/src/apps/mintable/component.cairo @@ -0,0 +1,86 @@ +#[starknet::component] +pub mod ERC20MintableComponent { + use core::num::traits::Zero; + use openzeppelin::token::erc20::ERC20Component::InternalTrait; + use openzeppelin::token::erc20::ERC20Component; + use openzeppelin::token::erc20::erc20::ERC20Component::Transfer; + use starknet::ContractAddress; + use starknet::get_caller_address; + use starknet_ibc::apps::mintable::errors::MintableErrors; + use starknet_ibc::apps::mintable::interface::IERC20Mintable; + + #[storage] + struct Storage { + permission: ContractAddress, + } + + #[event] + #[derive(Drop, Debug, starknet::Event)] + pub enum Event {} + + #[embeddable_as(ERC20Mintable)] + pub impl ERC20MintableImpl< + TContractState, + +HasComponent, + +Drop, + impl ERC20: ERC20Component::HasComponent, + > of IERC20Mintable> { + fn permissioned_mint( + ref self: ComponentState, recipient: ContractAddress, amount: u256 + ) { + let permitted_minter = self.permission.read(); + assert(permitted_minter == get_caller_address(), MintableErrors::UNAUTHORIZED_MINTER); + + self.mint(recipient, amount); + } + + fn permissioned_burn( + ref self: ComponentState, account: ContractAddress, amount: u256 + ) { + let permitted_burner = self.permission.read(); + assert(permitted_burner == get_caller_address(), MintableErrors::UNAUTHORIZED_BURNER); + self.burn(account, amount); + } + } + + #[generate_trait] + pub impl ERC20MintableInternalImpl< + TContractState, + +HasComponent, + +Drop, + impl ERC20: ERC20Component::HasComponent + > of ERC20MintableInternalTrait { + fn initializer(ref self: ComponentState) { + self.permission.write(get_caller_address()); + } + + fn mint( + ref self: ComponentState, recipient: ContractAddress, amount: u256 + ) { + let mut erc20_comp = get_dep_component_mut!(ref self, ERC20); + assert(recipient.is_non_zero(), MintableErrors::MINT_TO_ZERO); + + erc20_comp.ERC20_total_supply.write(erc20_comp.ERC20_total_supply.read() + amount); + erc20_comp + .ERC20_balances + .write(recipient, erc20_comp.ERC20_balances.read(recipient) + amount); + + erc20_comp.emit(Transfer { from: Zero::zero(), to: recipient, value: amount }); + } + + fn burn(ref self: ComponentState, account: ContractAddress, amount: u256) { + let mut erc20_comp = get_dep_component_mut!(ref self, ERC20); + assert(account.is_non_zero(), MintableErrors::BURN_FROM_ZERO); + + let total_supply = erc20_comp.ERC20_total_supply.read(); + assert(total_supply >= amount, MintableErrors::INSUFFICIENT_SUPPLY); + erc20_comp.ERC20_total_supply.write(total_supply - amount); + + let balance = erc20_comp.ERC20_balances.read(account); + assert(balance >= amount, MintableErrors::INSUFFICIENT_BALANCE); + erc20_comp.ERC20_balances.write(account, balance - amount); + + erc20_comp.emit(Transfer { from: account, to: Zero::zero(), value: amount }); + } + } +} diff --git a/contracts/src/apps/mintable/errors.cairo b/contracts/src/apps/mintable/errors.cairo new file mode 100644 index 00000000..835b1726 --- /dev/null +++ b/contracts/src/apps/mintable/errors.cairo @@ -0,0 +1,9 @@ +pub mod MintableErrors { + pub const UNAUTHORIZED_MINTER: felt252 = 'Unauthorized minter'; + pub const UNAUTHORIZED_BURNER: felt252 = 'Unauthorized burner'; + pub const BURN_FROM_ZERO: felt252 = 'ERC20: burn from 0'; + pub const MINT_TO_ZERO: felt252 = 'ERC20: mint to 0'; + pub const INSUFFICIENT_BALANCE: felt252 = 'ERC20: insufficient balance'; + pub const INSUFFICIENT_SUPPLY: felt252 = 'ERC20: insufficient supply'; +} + diff --git a/contracts/src/apps/mintable/interface.cairo b/contracts/src/apps/mintable/interface.cairo new file mode 100644 index 00000000..20ae555c --- /dev/null +++ b/contracts/src/apps/mintable/interface.cairo @@ -0,0 +1,7 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC20Mintable { + fn permissioned_mint(ref self: TContractState, recipient: ContractAddress, amount: u256); + fn permissioned_burn(ref self: TContractState, account: ContractAddress, amount: u256); +} diff --git a/contracts/src/apps/transfer.cairo b/contracts/src/apps/transfer.cairo index ccc935ca..16f4b560 100644 --- a/contracts/src/apps/transfer.cairo +++ b/contracts/src/apps/transfer.cairo @@ -1,3 +1,4 @@ pub mod component; +pub mod errors; pub mod interface; pub mod types; diff --git a/contracts/src/apps/transfer/component.cairo b/contracts/src/apps/transfer/component.cairo index da6ab4dc..379c6f09 100644 --- a/contracts/src/apps/transfer/component.cairo +++ b/contracts/src/apps/transfer/component.cairo @@ -1,118 +1,470 @@ #[starknet::component] pub mod ICS20TransferComponent { use core::array::ArrayTrait; + use core::clone::Clone; + use core::num::traits::Zero; use core::option::OptionTrait; use core::starknet::SyscallResultTrait; - use core::traits::TryInto; - use openzeppelin::token::erc20::ERC20Component; + use openzeppelin::utils::serde::SerializedAppend; use starknet::ClassHash; use starknet::ContractAddress; - use starknet::get_caller_address; use starknet::get_contract_address; - use starknet::syscalls::deploy_syscall; - use starknet_ibc::apps::transfer::types::{PrefixedCoin, Memo}; - use starknet_ibc::core::types::{PortId, ChannelId}; + use starknet_ibc::apps::transfer::errors::TransferErrors; + use starknet_ibc::apps::transfer::interface::{ISendTransfer, IRecvPacket, ITokenAddress}; + use starknet_ibc::apps::transfer::types::{ + MsgTransfer, PrefixedDenom, Denom, DenomTrait, PacketData, TracePrefix, Memo, + TracePrefixTrait, ERC20TokenTrait, ERC20Token, MAXIMUM_MEMO_LENGTH, ValidateBasicTrait, + PrefixedDenomTrait + }; + use starknet_ibc::apps::transferrable::interface::ITransferrable; + use starknet_ibc::core::channel::types::Packet; + use starknet_ibc::core::host::types::{PortId, ChannelId, ChannelIdTrait}; #[storage] struct Storage { + erc20_class_hash: ClassHash, salt: felt252, - tokens: LegacyMap::, + ibc_token_name_to_address: LegacyMap, + ibc_token_address_to_name: LegacyMap, } #[event] - #[derive(Drop, starknet::Event)] + #[derive(Debug, Drop, starknet::Event)] pub enum Event { - TransferEvent: TransferEvent, + SendEvent: SendEvent, + RecvEvent: RecvEvent, } - #[derive(Drop, Serde, starknet::Event)] - pub struct TransferEvent { - sender: ContractAddress, - receiver: ContractAddress, - amount: u64, - denom: felt252, - memo: Memo, + #[derive(Debug, Drop, Serde, starknet::Event)] + pub struct SendEvent { + #[key] + pub sender: ContractAddress, + #[key] + pub receiver: ContractAddress, + #[key] + pub denom: PrefixedDenom, + pub amount: u256, + pub memo: Memo, + } + + #[derive(Debug, Drop, Serde, starknet::Event)] + pub struct RecvEvent { + #[key] + pub sender: ContractAddress, + #[key] + pub receiver: ContractAddress, + #[key] + pub denom: PrefixedDenom, + pub amount: u256, + pub memo: Memo, + pub success: bool, + } + + #[embeddable_as(SendTransfer)] + impl SendTransferImpl< + TContractState, + +HasComponent, + +ITransferrable, + +Drop + > of ISendTransfer> { + fn send_validate(self: @ComponentState, msg: MsgTransfer) { + self.get_contract().can_send(); + + msg.validate_basic(); + + match @msg.packet_data.denom.base { + Denom::Native(erc20_token) => { + self + .escrow_validate( + msg.packet_data.sender.clone(), + msg.port_id_on_a.clone(), + msg.chan_id_on_a.clone(), + erc20_token.clone(), + msg.packet_data.amount, + msg.packet_data.memo.clone(), + ); + }, + Denom::Hosted(_) => { + self + .burn_validate( + msg.packet_data.sender.clone(), + msg.packet_data.denom.clone(), + msg.packet_data.amount, + msg.packet_data.memo.clone(), + ); + } + } + } + + fn send_execute(ref self: ComponentState, msg: MsgTransfer) { + self.send_validate(msg.clone()); + + match @msg.packet_data.denom.base { + Denom::Native(erc20_token) => { + self + .escrow_execute( + msg.packet_data.sender.clone(), + erc20_token.clone(), + msg.packet_data.amount, + msg.packet_data.memo.clone(), + ); + }, + Denom::Hosted(_) => { + self + .burn_execute( + msg.packet_data.sender.clone(), + msg.packet_data.denom.clone(), + msg.packet_data.amount, + msg.packet_data.memo.clone(), + ); + } + } + + self.emit_send_event(msg.packet_data); + } + } + + #[embeddable_as(RecvPacket)] + impl RecvPacketImpl< + TContractState, + +HasComponent, + +ITransferrable, + +Drop + > of IRecvPacket> { + fn recv_validate(self: @ComponentState, packet: Packet) { + self._recv_validate(packet); + } + + fn recv_execute(ref self: ComponentState, packet: Packet) { + self._recv_execute(packet); + } } #[generate_trait] - pub impl TransferValidationImpl< + impl RecvPacketInternalImpl< TContractState, +HasComponent, - impl ERC20MixinImpl: ERC20Component::HasComponent + +ITransferrable, + +Drop + > of RecvPacketInternalTrait { + fn _recv_validate(self: @ComponentState, packet: Packet) -> PacketData { + self.get_contract().can_receive(); + + let mut pakcet_data_span = packet.data.span(); + + let maybe_packet_data: Option = Serde::deserialize(ref pakcet_data_span); + + assert(maybe_packet_data.is_some(), TransferErrors::INVALID_PACKET_DATA); + + let packet_date = maybe_packet_data.unwrap(); + + packet_date.validate_basic(); + + match @packet_date.denom.base { + Denom::Native(erc20_token) => { + self + .unescrow_validate( + packet_date.receiver.clone(), + packet.port_id_on_a.clone(), + packet.chan_id_on_a.clone(), + erc20_token.clone(), + packet_date.amount, + ); + }, + Denom::Hosted(_) => { + self + .mint_validate( + packet_date.receiver.clone(), + packet_date.denom.clone(), + packet_date.amount + ); + } + } + + packet_date + } + + fn _recv_execute(ref self: ComponentState, packet: Packet) -> PacketData { + let mut packet_data = self._recv_validate(packet.clone()); + + let trace_prefix = TracePrefixTrait::new( + packet.port_id_on_a.clone(), packet.chan_id_on_a.clone() + ); + + match @packet_data.denom.base { + Denom::Native(erc20_token) => { + packet_data.denom.remove_prefix(@trace_prefix); + + self + .unescrow_execute( + packet_data.receiver.clone(), + packet.port_id_on_a.clone(), + packet.chan_id_on_a.clone(), + erc20_token.clone(), + packet_data.amount, + ) + }, + Denom::Hosted(_) => { + packet_data.denom.add_prefix(trace_prefix); + + self + .mint_execute( + packet_data.receiver.clone(), + packet_data.denom.clone(), + packet_data.amount + ) + } + }; + + self.emit_recv_event(packet_data.clone(), true); + + packet_data + } + } + + #[embeddable_as(IBCTokenAddress)] + impl ITokenAddressImpl< + TContractState, +HasComponent, +Drop + > of ITokenAddress> { + fn ibc_token_address( + self: @ComponentState, prefixed_denom: PrefixedDenom + ) -> Option { + let denom_key = prefixed_denom.compute_key(); + + let token_address = self.ibc_token_name_to_address.read(denom_key); + + if token_address.is_non_zero() { + Option::Some(token_address) + } else { + Option::None + } + } + } + + #[generate_trait] + pub(crate) impl TransferValidationImpl< + TContractState, +HasComponent, +Drop > of TransferValidationTrait { fn escrow_validate( self: @ComponentState, from_account: ContractAddress, port_id: PortId, channel_id: ChannelId, - coin: PrefixedCoin, + denom: ERC20Token, + amount: u256, memo: Memo, - ) {} + ) { + let balance = denom.balance_of(from_account); + + assert(balance >= amount, TransferErrors::INSUFFICIENT_BALANCE); + + self.assert_non_ibc_token(denom, port_id, channel_id); + } + fn unescrow_validate( self: @ComponentState, to_account: ContractAddress, port_id: PortId, channel_id: ChannelId, - coin: PrefixedCoin, - ) {} + denom: ERC20Token, + amount: u256, + ) { + let balance = denom.balance_of(get_contract_address()); + + assert(balance >= amount, TransferErrors::INSUFFICIENT_BALANCE); + + self.assert_non_ibc_token(denom, port_id, channel_id); + } + fn mint_validate( - self: @ComponentState, account: ContractAddress, coin: PrefixedCoin, - ) {} + self: @ComponentState, + account: ContractAddress, + denom: PrefixedDenom, + amount: u256, + ) { // NOTE: Normally, the minting process does not require any checks. + // However, an implementer might choose to incorporate custom + // checks, such as blacklisting. + } + fn burn_validate( self: @ComponentState, account: ContractAddress, - coin: PrefixedCoin, + denom: PrefixedDenom, + amount: u256, memo: Memo, - ) {} + ) { + let token_address: ERC20Token = self + .ibc_token_name_to_address + .read(denom.compute_key()) + .into(); + + let balance = token_address.balance_of(account); + + assert(balance >= amount, TransferErrors::INSUFFICIENT_BALANCE); + } } #[generate_trait] - pub impl TransferExecutionImpl< - TContractState, - +HasComponent, - impl ERC20MixinImpl: ERC20Component::HasComponent + pub(crate) impl TransferExecutionImpl< + TContractState, +HasComponent, +Drop > of TransferExecutionTrait { fn escrow_execute( ref self: ComponentState, from_account: ContractAddress, - port_id: felt252, - channel_id: felt252, - coin: PrefixedCoin, - memo: ByteArray, - ) {} + denom: ERC20Token, + amount: u256, + memo: Memo, + ) { + denom.transfer_from(from_account, get_contract_address(), amount); + } + fn unescrow_execute( ref self: ComponentState, to_account: ContractAddress, port_id: PortId, channel_id: ChannelId, - coin: PrefixedCoin, - ) {} + denom: ERC20Token, + amount: u256, + ) { + denom.transfer(to_account, amount); + } + fn mint_execute( - ref self: ComponentState, account: ContractAddress, coin: PrefixedCoin, - ) {} + ref self: ComponentState, + account: ContractAddress, + denom: PrefixedDenom, + amount: u256, + ) { + let token_address: ERC20Token = self + .ibc_token_name_to_address + .read(denom.compute_key()) + .into(); + + if token_address.is_non_zero() { + token_address.mint(account, amount); + } else { + self.create_token(account, denom, amount); + } + } + fn burn_execute( ref self: ComponentState, account: ContractAddress, - coin: PrefixedCoin, + denom: PrefixedDenom, + amount: u256, memo: Memo, - ) {} + ) { + let token_address: ERC20Token = self + .ibc_token_name_to_address + .read(denom.compute_key()) + .into(); + + token_address.burn(account, amount); + } } #[generate_trait] - pub impl InternalImpl< - TContractState, - +HasComponent, - impl ERC20MixinImpl: ERC20Component::HasComponent - > of InternalTrait { - fn create_token(ref self: ComponentState) -> ContractAddress { - // unimplemented! > Dummy value to pass the type check - 0.try_into().unwrap() - } - fn get_escrow_address( - self: @ComponentState, port_id: felt252, channel_id: felt252 + pub(crate) impl TransferInternalImpl< + TContractState, +HasComponent, +Drop + > of TransferInternalTrait { + fn initializer(ref self: ComponentState, erc20_class_hash: ClassHash) { + self.erc20_class_hash.write(erc20_class_hash); + self.salt.write(0); + } + + fn create_token( + ref self: ComponentState, + account: ContractAddress, + denom: PrefixedDenom, + amount: u256, ) -> ContractAddress { - // unimplemented! > Dummy value to pass the type check - 0.try_into().unwrap() + let salt = self.salt.read(); + + let name = denom.base.hosted().unwrap(); + + let erc20_token = ERC20TokenTrait::create( + self.erc20_class_hash.read(), + salt, + name.clone(), + name, // TODO: Detemine the symbol + amount, + account, + get_contract_address() + ); + + self.record_ibc_token(denom, erc20_token.address); + + self.salt.write(salt + 1); + + erc20_token.address + } + + fn record_ibc_token( + ref self: ComponentState, + denom: PrefixedDenom, + token_address: ContractAddress, + ) { + let denom_key = denom.compute_key(); + + self.ibc_token_name_to_address.write(denom_key, token_address); + + self.ibc_token_address_to_name.write(token_address, denom_key); + } + + fn assert_non_ibc_token( + self: @ComponentState, + denom: ERC20Token, + port_id: PortId, + channel_id: ChannelId, + ) { + let token_key = self.ibc_token_address_to_name.read(denom.address); + + if token_key.is_non_zero() { + let trace_prefix = TracePrefixTrait::new(port_id, channel_id); + + let denom = PrefixedDenom { + trace_path: array![trace_prefix], base: Denom::Native(denom), + }; + + // Checks if the token is an IBC-created token. If so, it cannot + // be transferred back to the source by escrowing. A prefixed + // denom should be passed to burn instead. + assert(token_key == denom.compute_key(), TransferErrors::INVALID_DENOM); + } + } + } + + #[generate_trait] + pub(crate) impl TransferEventImpl< + TContractState, +HasComponent, +Drop + > of TransferEventTrait { + fn emit_send_event(ref self: ComponentState, packet_date: PacketData,) { + self + .emit( + SendEvent { + sender: packet_date.sender, + receiver: packet_date.receiver, + denom: packet_date.denom, + amount: packet_date.amount, + memo: packet_date.memo, + } + ); + } + + fn emit_recv_event( + ref self: ComponentState, packet_date: PacketData, success: bool, + ) { + self + .emit( + RecvEvent { + sender: packet_date.sender, + receiver: packet_date.receiver, + denom: packet_date.denom, + amount: packet_date.amount, + memo: packet_date.memo, + success, + } + ); } } } diff --git a/contracts/src/apps/transfer/errors.cairo b/contracts/src/apps/transfer/errors.cairo new file mode 100644 index 00000000..3f715725 --- /dev/null +++ b/contracts/src/apps/transfer/errors.cairo @@ -0,0 +1,10 @@ +pub mod TransferErrors { + pub const INVALID_SENDER: felt252 = 'ICS20: Invalid sender account'; + pub const INVALID_RECEIVER: felt252 = 'ICS20: Invalid receiver account'; + pub const ZERO_AMOUNT: felt252 = 'ICS20: transfer amount is 0'; + pub const INVALID_TOKEN_ADDRESS: felt252 = 'ICS20: invalid token address'; + pub const INVALID_DENOM: felt252 = 'ICS20: invalid denom'; + pub const INVALID_PACKET_DATA: felt252 = 'ICS20: invalid packet data'; + pub const MAXIMUM_MEMO_LENGTH: felt252 = 'ICS20: memo exceeds max length'; + pub const INSUFFICIENT_BALANCE: felt252 = 'ICS20: insufficient balance'; +} diff --git a/contracts/src/apps/transfer/interface.cairo b/contracts/src/apps/transfer/interface.cairo index 534fb96f..94b1102c 100644 --- a/contracts/src/apps/transfer/interface.cairo +++ b/contracts/src/apps/transfer/interface.cairo @@ -1,8 +1,23 @@ use starknet::ContractAddress; -use starknet_ibc::apps::transfer::types::MsgTransfer; +use starknet_ibc::apps::transfer::types::{MsgTransfer, PrefixedDenom}; +use starknet_ibc::core::channel::types::Packet; #[starknet::interface] -pub trait ITransfer { - fn send_transfer(self: @TContractState, msg: MsgTransfer,); +pub trait ISendTransfer { + fn send_validate(self: @TContractState, msg: MsgTransfer); + fn send_execute(ref self: TContractState, msg: MsgTransfer); +} + +#[starknet::interface] +pub trait IRecvPacket { + fn recv_validate(self: @TContractState, packet: Packet); + fn recv_execute(ref self: TContractState, packet: Packet); +} + +#[starknet::interface] +pub trait ITokenAddress { + fn ibc_token_address( + self: @TContractState, prefixed_denom: PrefixedDenom + ) -> Option; } diff --git a/contracts/src/apps/transfer/types.cairo b/contracts/src/apps/transfer/types.cairo index 7a271a38..f73e8bb0 100644 --- a/contracts/src/apps/transfer/types.cairo +++ b/contracts/src/apps/transfer/types.cairo @@ -1,28 +1,265 @@ +use core::num::traits::Zero; +use core::serde::Serde; +use core::starknet::SyscallResultTrait; +use core::to_byte_array::FormatAsByteArray; +use core::traits::Into; +use core::traits::TryInto; +use openzeppelin::token::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; +use openzeppelin::utils::serde::SerializedAppend; +use starknet::ClassHash; use starknet::ContractAddress; -use starknet_ibc::core::types::{PortId, ChannelId}; +use starknet::Store; +use starknet::syscalls::deploy_syscall; +use starknet_ibc::apps::mintable::interface::{ + IERC20MintableDispatcher, IERC20MintableDispatcherTrait +}; +use starknet_ibc::apps::transfer::errors::TransferErrors; +use starknet_ibc::core::client::types::{Height, Timestamp}; +use starknet_ibc::core::host::types::{PortId, PortIdTrait, ChannelId, ChannelIdTrait, Sequence}; -#[derive(Drop, Serde, Store)] -pub struct MsgTransfer { - port_id_on_a: PortId, - chan_id_on_a: ChannelId, - packet_data: PacketData, -} +/// Maximum memo length allowed for ICS-20 transfers. This bound corresponds to +/// the `MaximumMemoLength` in the `ibc-go`. +pub(crate) const MAXIMUM_MEMO_LENGTH: u32 = 32768; -#[derive(Drop, Serde, Store)] -pub struct PrefixedCoin { - denom: ByteArray, - amount: u64, +/// Message used to build an ICS20 token transfer packet. +#[derive(Clone, Debug, Drop, Serde, Store)] +pub struct MsgTransfer { + pub port_id_on_a: PortId, + pub chan_id_on_a: ChannelId, + pub packet_data: PacketData, + pub timeout_height_on_b: Height, + pub timeout_timestamp_on_b: Timestamp, } -#[derive(Drop, Serde, Store)] -pub struct Memo { - memo: ByteArray, +impl MsgTransferValidateBasicImpl of ValidateBasicTrait { + fn validate_basic(self: @MsgTransfer) { + // self.port_id_on_a.validate(); + // self.chan_id_on_a.validate(); + self.packet_data.validate_basic(); + } } -#[derive(Drop, Serde, Store)] +#[derive(Clone, Debug, Drop, Serde, Store)] pub struct PacketData { - pub token: PrefixedCoin, + pub denom: PrefixedDenom, + pub amount: u256, pub sender: ContractAddress, pub receiver: ContractAddress, pub memo: Memo, } + +impl PacketDataValidateBasicImpl of ValidateBasicTrait { + fn validate_basic(self: @PacketData) { + assert(self.sender.is_non_zero(), TransferErrors::INVALID_SENDER); + assert(self.receiver.is_non_zero(), TransferErrors::INVALID_RECEIVER); + assert(self.denom.base.is_non_zero(), TransferErrors::INVALID_DENOM); + assert(self.amount.is_non_zero(), TransferErrors::ZERO_AMOUNT); + self.memo.validate_basic(); + } +} + +#[derive(Clone, Debug, Drop, Serde, Store)] +pub struct PrefixedDenom { + pub trace_path: Array, + pub base: Denom, +} + +pub trait PrefixedDenomTrait { + fn starts_with(self: @PrefixedDenom, prefix: @TracePrefix) -> bool; + fn add_prefix(ref self: PrefixedDenom, prefix: TracePrefix); + fn remove_prefix(ref self: PrefixedDenom, prefix: @TracePrefix); + fn compute_key(self: @PrefixedDenom) -> felt252; +} + +impl PrefixedDenomImpl of PrefixedDenomTrait { + fn starts_with(self: @PrefixedDenom, prefix: @TracePrefix) -> bool { + self.trace_path.at(0) == prefix + } + + fn add_prefix(ref self: PrefixedDenom, prefix: TracePrefix) { + self.trace_path.append(prefix); + } + + fn remove_prefix(ref self: PrefixedDenom, prefix: @TracePrefix) { + if self.starts_with(prefix) { + self.trace_path.pop_front().unwrap(); + } + } + + fn compute_key(self: @PrefixedDenom) -> felt252 { + 1 + } +} + +#[derive(Clone, Debug, Drop, PartialEq, Eq, Serde, Store)] +pub struct TracePrefix { + pub port_id: PortId, + pub channel_id: ChannelId, +} + +pub trait TracePrefixTrait { + fn new(port_id: PortId, channel_id: ChannelId) -> TracePrefix; + fn shorthand(self: @TracePrefix) -> ByteArray; +} + +impl TracePrefixImpl of TracePrefixTrait { + fn new(port_id: PortId, channel_id: ChannelId) -> TracePrefix { + TracePrefix { port_id: port_id, channel_id: channel_id, } + } + + fn shorthand(self: @TracePrefix) -> ByteArray { + assert(self.port_id == @PortIdTrait::transfer(), 'port_id must be transfer'); + let mut shorthand: ByteArray = ""; + shorthand.append(@"tr"); + shorthand.append(@"/"); + shorthand.append(@self.channel_id.index().format_as_byte_array(10)); + shorthand + } +} + +#[derive(Clone, Debug, Drop, Serde, Store)] +pub enum Denom { + Native: ERC20Token, + Hosted: ByteArray, +} + +#[derive(Clone, Debug, Drop, Serde, Store)] +pub struct ERC20Token { + pub address: ContractAddress, +} + +impl ContractAddressIntoTokenAddr of Into { + fn into(self: ContractAddress) -> ERC20Token { + ERC20Token { address: self } + } +} + +impl ERC20TokenIntoFelt252 of Into { + fn into(self: ERC20Token) -> felt252 { + self.address.into() + } +} + +pub trait ERC20TokenTrait { + fn is_non_zero(self: @ERC20Token) -> bool; + fn create( + class_hash: ClassHash, + salt: felt252, + name: ByteArray, + symbol: ByteArray, + amount: u256, + recipient: ContractAddress, + owner: ContractAddress + ) -> ERC20Token; + fn transfer(self: @ERC20Token, recipient: ContractAddress, amount: u256) -> bool; + fn transfer_from( + self: @ERC20Token, sender: ContractAddress, recipient: ContractAddress, amount: u256 + ) -> bool; + fn mint(self: @ERC20Token, recipient: ContractAddress, amount: u256); + fn burn(self: @ERC20Token, account: ContractAddress, amount: u256); + fn balance_of(self: @ERC20Token, from_account: ContractAddress) -> u256; +} + +impl ERC20TokenImpl of ERC20TokenTrait { + fn is_non_zero(self: @ERC20Token) -> bool { + self.address.is_non_zero() + } + + fn create( + class_hash: ClassHash, + salt: felt252, + name: ByteArray, + symbol: ByteArray, + amount: u256, + recipient: ContractAddress, + owner: ContractAddress + ) -> ERC20Token { + let mut call_data = array![]; + + call_data.append_serde(name); + call_data.append_serde(symbol); + call_data.append_serde(amount); + call_data.append_serde(recipient); + call_data.append_serde(owner); + + let (address, _) = deploy_syscall(class_hash, salt, call_data.span(), false,) + .unwrap_syscall(); + + ERC20Token { address } + } + + fn transfer(self: @ERC20Token, recipient: ContractAddress, amount: u256) -> bool { + ERC20ABIDispatcher { contract_address: *self.address }.transfer(recipient, amount) + } + + fn transfer_from( + self: @ERC20Token, sender: ContractAddress, recipient: ContractAddress, amount: u256 + ) -> bool { + ERC20ABIDispatcher { contract_address: *self.address } + .transfer_from(sender, recipient, amount) + } + + fn mint(self: @ERC20Token, recipient: ContractAddress, amount: u256) { + IERC20MintableDispatcher { contract_address: *self.address } + .permissioned_mint(recipient, amount) + } + + fn burn(self: @ERC20Token, account: ContractAddress, amount: u256) { + IERC20MintableDispatcher { contract_address: *self.address } + .permissioned_burn(account, amount) + } + + fn balance_of(self: @ERC20Token, from_account: ContractAddress) -> u256 { + ERC20ABIDispatcher { contract_address: *self.address }.balance_of(from_account) + } +} + +pub trait DenomTrait { + fn is_non_zero(self: @Denom) -> bool; + fn native(self: @Denom) -> Option; + fn hosted(self: @Denom) -> Option; +} + +pub impl DenomImpl of DenomTrait { + fn is_non_zero(self: @Denom) -> bool { + match self { + Denom::Native(token_addr) => token_addr.is_non_zero(), + Denom::Hosted(byte_array) => byte_array.len() != 0, + } + } + + fn native(self: @Denom) -> Option { + match self { + Denom::Native(contract_address) => Option::Some(*contract_address.address), + Denom::Hosted(_) => Option::None, + } + } + + fn hosted(self: @Denom) -> Option { + match self { + Denom::Native(_) => Option::None, + Denom::Hosted(base) => Option::Some(base.clone()) + } + } +} + +pub impl ContractAddressIntoDenom of Into { + fn into(self: ContractAddress) -> Denom { + Denom::Native(ERC20Token { address: self }) + } +} + +#[derive(Clone, Debug, Drop, Serde, Store)] +pub struct Memo { + pub memo: ByteArray, +} + +impl MemoValidateBasicImpl of ValidateBasicTrait { + fn validate_basic(self: @Memo) { + assert(self.memo.len() <= MAXIMUM_MEMO_LENGTH, TransferErrors::MAXIMUM_MEMO_LENGTH); + } +} + +pub trait ValidateBasicTrait { + fn validate_basic(self: @T); +} diff --git a/contracts/src/apps/transferrable.cairo b/contracts/src/apps/transferrable.cairo new file mode 100644 index 00000000..3e1ae43c --- /dev/null +++ b/contracts/src/apps/transferrable.cairo @@ -0,0 +1,3 @@ +pub mod component; +pub mod errors; +pub mod interface; diff --git a/contracts/src/apps/transferrable/component.cairo b/contracts/src/apps/transferrable/component.cairo new file mode 100644 index 00000000..74e51c7f --- /dev/null +++ b/contracts/src/apps/transferrable/component.cairo @@ -0,0 +1,40 @@ +#[starknet::component] +pub mod TransferrableComponent { + use starknet_ibc::apps::transferrable::errors::TransferrableErrors; + use starknet_ibc::apps::transferrable::interface::ITransferrable; + + #[storage] + struct Storage { + send_capability: bool, + receive_capability: bool, + } + + #[event] + #[derive(Drop, Debug, starknet::Event)] + pub enum Event {} + + #[embeddable_as(Transferrable)] + pub impl TransferrableImpl< + TContractState, +HasComponent, +Drop + > of ITransferrable> { + fn can_send(self: @ComponentState) { + let send_capability = self.send_capability.read(); + assert(send_capability, TransferrableErrors::NO_SEND_CAPABILITY); + } + fn can_receive(self: @ComponentState) { + let receive_capability = self.receive_capability.read(); + assert(receive_capability, TransferrableErrors::NO_RECEIVE_CAPABILITY); + } + } + + #[generate_trait] + pub impl TransferrableInternalImpl< + TContractState, +HasComponent, +Drop + > of TransferrableInternalTrait { + fn initializer(ref self: ComponentState) { + self.send_capability.write(true); + self.receive_capability.write(true); + } + } +} + diff --git a/contracts/src/apps/transferrable/errors.cairo b/contracts/src/apps/transferrable/errors.cairo new file mode 100644 index 00000000..0cf9b542 --- /dev/null +++ b/contracts/src/apps/transferrable/errors.cairo @@ -0,0 +1,4 @@ +pub mod TransferrableErrors { + pub const NO_SEND_CAPABILITY: felt252 = 'ICS20: No send capability'; + pub const NO_RECEIVE_CAPABILITY: felt252 = 'ICS20: No receive capability'; +} diff --git a/contracts/src/apps/transferrable/interface.cairo b/contracts/src/apps/transferrable/interface.cairo new file mode 100644 index 00000000..0a62332c --- /dev/null +++ b/contracts/src/apps/transferrable/interface.cairo @@ -0,0 +1,6 @@ +#[starknet::interface] +pub trait ITransferrable { + fn can_send(self: @TContractState); + fn can_receive(self: @TContractState); +} + diff --git a/contracts/src/contracts/transfer.cairo b/contracts/src/contracts/transfer.cairo deleted file mode 100644 index 9a900d5b..00000000 --- a/contracts/src/contracts/transfer.cairo +++ /dev/null @@ -1,72 +0,0 @@ -#[starknet::contract] -pub(crate) mod Transfer { - use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; - use starknet::{ContractAddress, ClassHash}; - use starknet_ibc::apps::transfer::component::ICS20TransferComponent; - - component!(path: ERC20Component, storage: erc20, event: ERC20Event); - component!(path: ICS20TransferComponent, storage: transfer, event: ICS20TransferEvent); - - #[abi(embed_v0)] - impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl; - impl ERC20InternalImpl = ERC20Component::InternalImpl; - - impl TransferValidationImpl = ICS20TransferComponent::TransferValidationImpl; - impl TransferExecutionImpl = ICS20TransferComponent::TransferExecutionImpl; - impl TransferInternalImpl = ICS20TransferComponent::InternalImpl; - - #[storage] - struct Storage { - #[substorage(v0)] - erc20: ERC20Component::Storage, - #[substorage(v0)] - transfer: ICS20TransferComponent::Storage, - } - - #[event] - #[derive(Drop, starknet::Event)] - enum Event { - #[flat] - ERC20Event: ERC20Component::Event, - #[flat] - ICS20TransferEvent: ICS20TransferComponent::Event, - } - - #[constructor] - fn constructor( - ref self: ContractState, - name: ByteArray, - symbol: ByteArray, - fixed_supply: u256, - recipient: ContractAddress, - owner: ContractAddress - ) { - self.erc20.initializer(name, symbol); - } -} - -#[cfg(test)] -mod tests { - use core::starknet::SyscallResultTrait; - use starknet::ContractAddress; - use starknet::contract_address_const; - use starknet::syscalls::deploy_syscall; - use starknet_ibc::Transfer; - use starknet_ibc::apps::transfer::interface::{ITransferDispatcher, ITransferDispatcherTrait,}; - - fn deploy() -> (ITransferDispatcher, ContractAddress) { - let recipient: ContractAddress = contract_address_const::<'sender'>(); - - let (contract_address, _) = deploy_syscall( - Transfer::TEST_CLASS_HASH.try_into().unwrap(), recipient.into(), array![0].span(), false - ) - .unwrap_syscall(); - - (ITransferDispatcher { contract_address }, contract_address) - } - - #[test] - fn test_transfer() { - deploy(); - } -} diff --git a/contracts/src/core.cairo b/contracts/src/core.cairo index cd408564..ac81ab6e 100644 --- a/contracts/src/core.cairo +++ b/contracts/src/core.cairo @@ -1 +1,3 @@ -pub mod types; +pub mod channel; +pub mod client; +pub mod host; diff --git a/contracts/src/core/channel.cairo b/contracts/src/core/channel.cairo new file mode 100644 index 00000000..cd408564 --- /dev/null +++ b/contracts/src/core/channel.cairo @@ -0,0 +1 @@ +pub mod types; diff --git a/contracts/src/core/channel/types.cairo b/contracts/src/core/channel/types.cairo new file mode 100644 index 00000000..43acd353 --- /dev/null +++ b/contracts/src/core/channel/types.cairo @@ -0,0 +1,14 @@ +use starknet_ibc::core::client::types::{Height, Timestamp}; +use starknet_ibc::core::host::types::{ChannelId, PortId, Sequence}; + +#[derive(Clone, Debug, Drop, Serde, Store)] +pub struct Packet { + pub seq_on_a: Sequence, + pub port_id_on_a: PortId, + pub chan_id_on_a: ChannelId, + pub port_id_on_b: PortId, + pub chan_id_on_b: ChannelId, + pub data: Array, + pub timeout_height_on_b: Height, + pub timeout_timestamp_on_b: Timestamp, +} diff --git a/contracts/src/core/client.cairo b/contracts/src/core/client.cairo new file mode 100644 index 00000000..cd408564 --- /dev/null +++ b/contracts/src/core/client.cairo @@ -0,0 +1 @@ +pub mod types; diff --git a/contracts/src/core/client/types.cairo b/contracts/src/core/client/types.cairo new file mode 100644 index 00000000..f6cbca37 --- /dev/null +++ b/contracts/src/core/client/types.cairo @@ -0,0 +1,10 @@ +#[derive(Clone, Debug, Drop, PartialEq, Eq, Serde, Store)] +pub struct Height { + pub revision_number: u64, + pub revision_height: u64, +} + +#[derive(Clone, Debug, Drop, PartialEq, Eq, Serde, Store)] +pub struct Timestamp { + pub timestamp: u64, +} diff --git a/contracts/src/core/host.cairo b/contracts/src/core/host.cairo new file mode 100644 index 00000000..422d6694 --- /dev/null +++ b/contracts/src/core/host.cairo @@ -0,0 +1,2 @@ +pub mod errors; +pub mod types; diff --git a/contracts/src/core/host/errors.cairo b/contracts/src/core/host/errors.cairo new file mode 100644 index 00000000..bcf5ad81 --- /dev/null +++ b/contracts/src/core/host/errors.cairo @@ -0,0 +1,6 @@ +pub mod HostErrors { + pub const INVALID_IDENTIFIER_LENGTH: felt252 = 'ICS24: invalid ID length'; + pub const INVALID_IDENTIFIER_PREFIX: felt252 = 'ICS24: invalid ID prefix'; + pub const INVALID_IDENTIFIER_INDEX: felt252 = 'ICS24: invalid ID index'; + pub const INVALID_IDENTIFIER_CHAR: felt252 = 'ICS24: invalid ID character'; +} diff --git a/contracts/src/core/host/types.cairo b/contracts/src/core/host/types.cairo new file mode 100644 index 00000000..c54bef89 --- /dev/null +++ b/contracts/src/core/host/types.cairo @@ -0,0 +1,146 @@ +use core::byte_array::ByteArrayTrait; +use core::to_byte_array::FormatAsByteArray; +use core::traits::TryInto; +use starknet::ContractAddress; +use starknet::Store; +use starknet_ibc::core::host::errors::HostErrors; +use starknet_ibc::utils::{ToFelt252Trait, bytes_to_felt252}; + +#[derive(Clone, Debug, Drop, PartialEq, Eq, Serde, Store)] +pub struct ChannelId { + pub channel_id: ByteArray, +} + +pub trait ChannelIdTrait { + fn new(index: u64) -> ChannelId; + fn index(self: @ChannelId) -> u64; + fn validate(self: @ChannelId); + fn abbr(self: @ChannelId) -> ByteArray; +} + +pub impl ChannelIdImpl of ChannelIdTrait { + fn new(index: u64) -> ChannelId { + let mut channel_id: ByteArray = ""; + channel_id.append(@"channel-"); + channel_id.append(@index.format_as_byte_array(10)); + ChannelId { channel_id } + } + + fn index(self: @ChannelId) -> u64 { + let mut i = self.channel_id.len() - 1; + let mut j: u64 = 0; + let mut index: u64 = 0; + + loop { + if i == 8 { + break; + } + let char_byte = self.channel_id.at(i).unwrap(); + index += j * 10 * (char_byte - 48).into(); + i -= 1; + j += 1; + }; + + index + } + + fn validate(self: @ChannelId) { + let channel_id_len = self.channel_id.len(); + + assert(channel_id_len > 8, HostErrors::INVALID_IDENTIFIER_LENGTH); + assert(channel_id_len < 32, HostErrors::INVALID_IDENTIFIER_LENGTH); + + let prefix: ByteArray = "channel-"; + + let mut i = 0; + + loop { + if i == channel_id_len - 1 { + break; + } + + let char_byte = self.channel_id.at(i).unwrap(); + + validate_char(char_byte); + + if i <= 7 { + assert(char_byte == prefix.at(i).unwrap(), HostErrors::INVALID_IDENTIFIER_PREFIX); + } else { + /// Checks if the index starts with 0 it does not contain any leading zeros. + if i == 8 && char_byte == 48 { + assert(i == channel_id_len - 1, HostErrors::INVALID_IDENTIFIER_INDEX); + } + assert_numeric(char_byte); + } + } + } + + fn abbr(self: @ChannelId) -> ByteArray { + let mut abbr: ByteArray = ""; + abbr.append(@"ch"); + abbr.append(@self.index().format_as_byte_array(10)); + abbr + } +} + +impl ChannelIdToFelt252 of ToFelt252Trait { + fn try_to_felt252(self: @ChannelId) -> Option { + bytes_to_felt252(self.channel_id) + } +} + +#[derive(Clone, Debug, Drop, PartialEq, Eq, Serde, Store)] +pub struct PortId { + pub port_id: ByteArray, +} + +pub trait PortIdTrait { + fn validate(self: @PortId); + fn transfer() -> PortId; +} + +pub impl PortIdImpl of PortIdTrait { + fn validate(self: @PortId) { + let port_id_len = self.port_id.len(); + + assert(port_id_len > 2, HostErrors::INVALID_IDENTIFIER_LENGTH); + assert(port_id_len < 32, HostErrors::INVALID_IDENTIFIER_LENGTH); + + let mut i = 0; + + loop { + if i == port_id_len - 1 { + break; + } + + let char_byte = self.port_id.at(i).unwrap(); + + validate_char(char_byte); + } + } + + fn transfer() -> PortId { + PortId { port_id: "transfer", } + } +} + +impl PortIdToFelt252 of ToFelt252Trait { + fn try_to_felt252(self: @PortId) -> Option { + bytes_to_felt252(self.port_id) + } +} + +#[derive(Clone, Debug, Drop, PartialEq, Eq, Serde, Store)] +pub struct Sequence { + pub sequence: u64, +} + +/// Validates if the given byte is a valid identifier character. +pub(crate) fn validate_char(char_bytes: u8) { + assert(char_bytes != 47, HostErrors::INVALID_IDENTIFIER_CHAR); // '/' + assert(char_bytes >= 33, HostErrors::INVALID_IDENTIFIER_CHAR); // Non-printable ASCII characters +} + +pub(crate) fn assert_numeric(char_bytes: u8) { + assert(char_bytes >= 48 && char_bytes <= 57, HostErrors::INVALID_IDENTIFIER_CHAR); +} diff --git a/contracts/src/core/types.cairo b/contracts/src/core/types.cairo deleted file mode 100644 index edfe12cc..00000000 --- a/contracts/src/core/types.cairo +++ /dev/null @@ -1,12 +0,0 @@ -use starknet::ContractAddress; -use starknet::Store; - -#[derive(Drop, Serde, Store)] -pub struct ChannelId { - channel_id: felt252, -} - -#[derive(Drop, Serde, Store)] -pub struct PortId { - port_id: felt252, -} diff --git a/contracts/src/lib.cairo b/contracts/src/lib.cairo index fad72ee8..17622a3a 100644 --- a/contracts/src/lib.cairo +++ b/contracts/src/lib.cairo @@ -1,2 +1,5 @@ pub mod apps; pub mod core; +pub mod presets; +pub mod tests; +pub mod utils; diff --git a/contracts/src/presets.cairo b/contracts/src/presets.cairo new file mode 100644 index 00000000..214f2d13 --- /dev/null +++ b/contracts/src/presets.cairo @@ -0,0 +1,7 @@ +mod erc20; +mod transfer; + +#[cfg(test)] +pub use erc20::ERC20; +pub use transfer::Transfer; + diff --git a/contracts/src/presets/erc20.cairo b/contracts/src/presets/erc20.cairo new file mode 100644 index 00000000..43ba780e --- /dev/null +++ b/contracts/src/presets/erc20.cairo @@ -0,0 +1,62 @@ +#[starknet::contract] +pub(crate) mod ERC20 { + use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; + use starknet::{ContractAddress, ClassHash}; + use starknet_ibc::apps::mintable::component::ERC20MintableComponent::ERC20MintableInternalTrait; + use starknet_ibc::apps::mintable::component::ERC20MintableComponent; + + 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; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + // ERC20 Mintable + #[abi(embed_v0)] + impl ERC20MintableImpl = ERC20MintableComponent::ERC20Mintable; + + // ERC20 Mixin + #[abi(embed_v0)] + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl; + impl ERC20InternalImpl = ERC20Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + mintable: ERC20MintableComponent::Storage, + #[substorage(v0)] + erc20: ERC20Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + MintableEvent: ERC20MintableComponent::Event, + #[flat] + ERC20Event: ERC20Component::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + fixed_supply: u256, + recipient: ContractAddress, + owner: ContractAddress + ) { + self.ownable.initializer(owner); + self.mintable.initializer(); + self.erc20.initializer(name, symbol); + self.erc20.mint(recipient, fixed_supply); + } +} diff --git a/contracts/src/presets/transfer.cairo b/contracts/src/presets/transfer.cairo new file mode 100644 index 00000000..4c2bf219 --- /dev/null +++ b/contracts/src/presets/transfer.cairo @@ -0,0 +1,58 @@ +#[starknet::contract] +pub(crate) mod Transfer { + use starknet::ClassHash; + use starknet_ibc::apps::governance::component::IBCGovernanceComponent::GovernanceInternalTrait; + use starknet_ibc::apps::governance::component::IBCGovernanceComponent; + use starknet_ibc::apps::transfer::component::ICS20TransferComponent; + use starknet_ibc::apps::transferrable::component::TransferrableComponent::TransferrableInternalTrait; + use starknet_ibc::apps::transferrable::component::TransferrableComponent; + + component!(path: IBCGovernanceComponent, storage: governance, event: IBCGovernanceEvent); + component!(path: TransferrableComponent, storage: transferrable, event: TransferrableEvent); + component!(path: ICS20TransferComponent, storage: transfer, event: ICS20TransferEvent); + + #[abi(embed_v0)] + impl IBCGovernanceImpl = IBCGovernanceComponent::Governance; + #[abi(embed_v0)] + impl ICS20TransferreableImpl = + TransferrableComponent::Transferrable; + #[abi(embed_v0)] + impl ICS20SendTransferImpl = + ICS20TransferComponent::SendTransfer; + #[abi(embed_v0)] + impl ICS20RecvPacketImpl = ICS20TransferComponent::RecvPacket; + #[abi(embed_v0)] + impl ICS20TokenAddressImpl = + ICS20TransferComponent::IBCTokenAddress; + impl TransferValidationImpl = ICS20TransferComponent::TransferValidationImpl; + impl TransferExecutionImpl = ICS20TransferComponent::TransferExecutionImpl; + impl TransferInternalImpl = ICS20TransferComponent::TransferInternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + governance: IBCGovernanceComponent::Storage, + #[substorage(v0)] + transferrable: TransferrableComponent::Storage, + #[substorage(v0)] + transfer: ICS20TransferComponent::Storage, + } + + #[event] + #[derive(Debug, Drop, starknet::Event)] + pub enum Event { + #[flat] + IBCGovernanceEvent: IBCGovernanceComponent::Event, + #[flat] + TransferrableEvent: TransferrableComponent::Event, + #[flat] + ICS20TransferEvent: ICS20TransferComponent::Event, + } + + #[constructor] + fn constructor(ref self: ContractState, erc20_class_hash: ClassHash) { + self.governance.initializer(); + self.transferrable.initializer(); + self.transfer.initializer(erc20_class_hash); + } +} diff --git a/contracts/src/tests.cairo b/contracts/src/tests.cairo new file mode 100644 index 00000000..8a28d1f0 --- /dev/null +++ b/contracts/src/tests.cairo @@ -0,0 +1,6 @@ +#[cfg(test)] +mod setup; +#[cfg(test)] +mod transfer; + +mod utils; diff --git a/contracts/src/tests/setup.cairo b/contracts/src/tests/setup.cairo new file mode 100644 index 00000000..8c2a745f --- /dev/null +++ b/contracts/src/tests/setup.cairo @@ -0,0 +1,97 @@ +use core::option::OptionTrait; +use openzeppelin::tests::utils::{deploy, pop_log}; +use openzeppelin::token::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; +use openzeppelin::utils::serde::SerializedAppend; +use starknet::ContractAddress; +use starknet::testing; +use starknet_ibc::apps::transfer::component::ICS20TransferComponent::{ + Event as TransferEvent, SendEvent, RecvEvent +}; +use starknet_ibc::apps::transfer::interface::{ + ISendTransferDispatcher, IRecvPacketDispatcher, ITokenAddressDispatcher, +}; +use starknet_ibc::presets::{Transfer, ERC20}; +use starknet_ibc::tests::utils::dummy_erc20_call_data; + +#[derive(Clone, Debug, Drop, PartialEq, Eq)] +pub struct ICS20TransferContract { + pub contract_address: ContractAddress, +} + +pub trait ICS20TransferContractTrait { + fn setup() -> ICS20TransferContract; + fn send_dispatcher(self: @ICS20TransferContract) -> ISendTransferDispatcher; + fn recv_dispatcher(self: @ICS20TransferContract) -> IRecvPacketDispatcher; + fn addr_dispatcher(self: @ICS20TransferContract) -> ITokenAddressDispatcher; + fn pop_event(self: @ICS20TransferContract) -> Option; + fn assert_send_event(self: @ICS20TransferContract) -> SendEvent; + fn assert_recv_event(self: @ICS20TransferContract) -> RecvEvent; +} + +pub impl ICS20TransferContractImpl of ICS20TransferContractTrait { + fn setup() -> ICS20TransferContract { + let mut call_data = array![]; + call_data.append_serde(ERC20::TEST_CLASS_HASH); + + let contract_address = deploy(Transfer::TEST_CLASS_HASH, call_data); + + ICS20TransferContract { contract_address } + } + + fn send_dispatcher(self: @ICS20TransferContract) -> ISendTransferDispatcher { + ISendTransferDispatcher { contract_address: *self.contract_address } + } + + fn recv_dispatcher(self: @ICS20TransferContract) -> IRecvPacketDispatcher { + IRecvPacketDispatcher { contract_address: *self.contract_address } + } + + fn addr_dispatcher(self: @ICS20TransferContract) -> ITokenAddressDispatcher { + ITokenAddressDispatcher { contract_address: *self.contract_address } + } + + fn pop_event(self: @ICS20TransferContract) -> Option { + pop_log(*self.contract_address) + } + + fn assert_send_event(self: @ICS20TransferContract) -> SendEvent { + match self.pop_event().expect('no event') { + TransferEvent::SendEvent(e) => e, + _ => panic!("unexpected event"), + } + } + + fn assert_recv_event(self: @ICS20TransferContract) -> RecvEvent { + match self.pop_event().expect('no event') { + TransferEvent::RecvEvent(e) => e, + _ => panic!("unexpected event"), + } + } +} + +#[derive(Clone, Debug, Drop, PartialEq, Eq)] +pub struct ERC20Contract { + pub contract_address: ContractAddress, +} + +pub trait ERC20ContractTrait { + fn setup() -> ERC20Contract; + fn setup_with_addr(contract_address: ContractAddress) -> ERC20Contract; + fn dispatcher(self: @ERC20Contract) -> ERC20ABIDispatcher; +} + +pub impl ERC20ContractImpl of ERC20ContractTrait { + fn setup() -> ERC20Contract { + let contract_address = deploy(ERC20::TEST_CLASS_HASH, dummy_erc20_call_data()); + + ERC20Contract { contract_address } + } + + fn setup_with_addr(contract_address: ContractAddress) -> ERC20Contract { + ERC20Contract { contract_address } + } + + fn dispatcher(self: @ERC20Contract) -> ERC20ABIDispatcher { + ERC20ABIDispatcher { contract_address: *self.contract_address } + } +} diff --git a/contracts/src/tests/transfer.cairo b/contracts/src/tests/transfer.cairo new file mode 100644 index 00000000..c9a161f5 --- /dev/null +++ b/contracts/src/tests/transfer.cairo @@ -0,0 +1,127 @@ +use openzeppelin::token::erc20::ERC20ABIDispatcherTrait; +use starknet::ContractAddress; +use starknet::testing; +use starknet_ibc::apps::transfer::component::ICS20TransferComponent::Event as TransferEvent; +use starknet_ibc::apps::transfer::interface::{ + ISendTransferDispatcherTrait, IRecvPacketDispatcherTrait, ITokenAddressDispatcherTrait +}; +use starknet_ibc::tests::setup::{ERC20ContractTrait, ICS20TransferContractTrait}; +use starknet_ibc::tests::utils::{ + AMOUNT, SUPPLY, HOSTED_PREFIXED_DENOM, OWNER, RECIPIENT, dummy_msg_transder, dummy_recv_packet, + NATIVE_PREFIXED_DENOM, BARE_DENOM +}; + +#[test] +fn test_escrow_unescrow_roundtrip() { + // ----------------------------------------------------------- + // Setup Contracts + // ----------------------------------------------------------- + + // Deploy an ERC20 contract. + let erc20 = ERC20ContractTrait::setup(); + + // Deploy an ICS20 Token Transfer contract. + let ics20 = ICS20TransferContractTrait::setup(); + + // ----------------------------------------------------------- + // Escrow + // ----------------------------------------------------------- + + // Owner approves the amount of allowance for the `Transfer` contract. + testing::set_contract_address(OWNER()); + erc20.dispatcher().approve(ics20.contract_address, AMOUNT); + + // Submit a `MsgTransfer` to the `Transfer` contract. + ics20 + .send_dispatcher() + .send_execute(dummy_msg_transder(BARE_DENOM(erc20.contract_address), OWNER(), RECIPIENT())); + + // Assert the `SendEvent` emitted. + let event = ics20.assert_send_event(); + + // Check the balance of the sender. + let sender_balance = erc20.dispatcher().balance_of(event.sender); + assert_eq!(sender_balance, SUPPLY - AMOUNT); + + // Check the balance of the `Transfer` contract. + let contract_balance = erc20.dispatcher().balance_of(ics20.contract_address); + assert_eq!(contract_balance, AMOUNT); + + // ----------------------------------------------------------- + // Unescrow + // ----------------------------------------------------------- + + // Submit a `RecvPacket` to the `Transfer` contract. + ics20 + .recv_dispatcher() + .recv_execute( + dummy_recv_packet(NATIVE_PREFIXED_DENOM(erc20.contract_address), OWNER(), RECIPIENT()) + ); + + // Assert the `RecvEvent` emitted. + let event = ics20.assert_recv_event(); + + let contract_balance = erc20.dispatcher().balance_of(ics20.contract_address); + assert_eq!(contract_balance, 0); + + // Check the balance of the recipient. + let receiver_balance = erc20.dispatcher().balance_of(event.receiver); + assert_eq!(receiver_balance, AMOUNT); +} + +#[test] +fn test_mint_burn_roundtrip() { + // ----------------------------------------------------------- + // Setup Contracts + // ----------------------------------------------------------- + + // Deploy an ICS20 Token Transfer contract. + let ics20 = ICS20TransferContractTrait::setup(); + + // ----------------------------------------------------------- + // Mint + // ----------------------------------------------------------- + + // Submit a `RecvPacket`, which will create a new ERC20 contract. + ics20 + .recv_dispatcher() + .recv_execute(dummy_recv_packet(HOSTED_PREFIXED_DENOM(), OWNER(), RECIPIENT())); + + // Assert the `RecvEvent` emitted. + ics20.assert_recv_event(); + + // Submit another `RecvPacket`, which will mint the amount of tokens. + ics20 + .recv_dispatcher() + .recv_execute(dummy_recv_packet(HOSTED_PREFIXED_DENOM(), OWNER(), RECIPIENT())); + + // Assert the `RecvEvent` emitted. + let event = ics20.assert_recv_event(); + + // Check the balance of the receiver. + let token_address = ics20.addr_dispatcher().ibc_token_address(HOSTED_PREFIXED_DENOM()).unwrap(); + + let erc20 = ERC20ContractTrait::setup_with_addr(token_address); + + let receiver_balance = erc20.dispatcher().balance_of(event.receiver); + assert_eq!(receiver_balance, AMOUNT * 2); + + // ----------------------------------------------------------- + // Burn + // ----------------------------------------------------------- + + // Owner approves the amount of allowance for the `Transfer` contract. + ics20 + .send_dispatcher() + .send_execute(dummy_msg_transder(HOSTED_PREFIXED_DENOM(), RECIPIENT(), OWNER())); + + // Assert the `SendEvent` emitted. + let event = ics20.assert_send_event(); + + // Check the balance of the sender. + let sender_balance = erc20.dispatcher().balance_of(event.sender); + assert_eq!(sender_balance, AMOUNT); + + let contract_balance = erc20.dispatcher().balance_of(ics20.contract_address); + assert_eq!(contract_balance, 0); +} diff --git a/contracts/src/tests/utils.cairo b/contracts/src/tests/utils.cairo new file mode 100644 index 00000000..921a33c7 --- /dev/null +++ b/contracts/src/tests/utils.cairo @@ -0,0 +1,99 @@ +use core::serde::Serde; +use starknet::ContractAddress; +use starknet::contract_address_const; +use starknet_ibc::apps::transfer::types::{ + MsgTransfer, PacketData, PrefixedDenom, Denom, Memo, TracePrefixTrait +}; +use starknet_ibc::core::channel::types::Packet; +use starknet_ibc::core::client::types::{Height, Timestamp}; +use starknet_ibc::core::host::types::{ChannelId, PortId, Sequence}; + +pub(crate) const TOKEN_NAME: felt252 = 'NAME'; +pub(crate) const DECIMALS: u8 = 18_u8; +pub(crate) const SUPPLY: u256 = 2000; +pub(crate) const AMOUNT: u256 = 100; +pub(crate) const SALT: felt252 = 'SALT'; + +pub(crate) fn NAME() -> ByteArray { + "NAME" +} + +pub(crate) fn SYMBOL() -> ByteArray { + "SYMBOL" +} + +pub(crate) fn BARE_DENOM(contract_address: ContractAddress) -> PrefixedDenom { + PrefixedDenom { trace_path: array![], base: Denom::Native(contract_address.into()) } +} + +pub(crate) fn NATIVE_PREFIXED_DENOM(contract_address: ContractAddress) -> PrefixedDenom { + let trace_prefix = TracePrefixTrait::new( + PortId { port_id: "transfer" }, ChannelId { channel_id: "channel-0" } + ); + PrefixedDenom { trace_path: array![trace_prefix], base: Denom::Native(contract_address.into()) } +} + +pub(crate) fn HOSTED_PREFIXED_DENOM() -> PrefixedDenom { + let trace_prefix = TracePrefixTrait::new( + PortId { port_id: "transfer" }, ChannelId { channel_id: "channel-0" } + ); + PrefixedDenom { trace_path: array![trace_prefix], base: Denom::Hosted("uatom") } +} + +pub(crate) fn PUBKEY() -> ContractAddress { + contract_address_const::<0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7>() +} + +pub(crate) fn OWNER() -> ContractAddress { + contract_address_const::<'OWNER'>() +} + +pub(crate) fn RECIPIENT() -> ContractAddress { + contract_address_const::<'RECIPIENT'>() +} + +pub(crate) fn dummy_erc20_call_data() -> Array { + let mut call_data: Array = array![]; + Serde::serialize(@NAME(), ref call_data); + Serde::serialize(@SYMBOL(), ref call_data); + Serde::serialize(@SUPPLY, ref call_data); + Serde::serialize(@OWNER(), ref call_data); + Serde::serialize(@OWNER(), ref call_data); + call_data +} + +pub(crate) fn dummy_msg_transder( + denom: PrefixedDenom, sender: ContractAddress, receiver: ContractAddress +) -> MsgTransfer { + MsgTransfer { + port_id_on_a: PortId { port_id: "transfer" }, + chan_id_on_a: ChannelId { channel_id: "channel-0" }, + packet_data: dummy_packet_data(denom, sender, receiver), + timeout_height_on_b: Height { revision_number: 0, revision_height: 1000 }, + timeout_timestamp_on_b: Timestamp { timestamp: 1000 } + } +} + +pub(crate) fn dummy_recv_packet( + denom: PrefixedDenom, sender: ContractAddress, receiver: ContractAddress +) -> Packet { + let mut serialized_data = array![]; + Serde::serialize(@dummy_packet_data(denom, sender, receiver), ref serialized_data); + + Packet { + seq_on_a: Sequence { sequence: 0 }, + port_id_on_a: PortId { port_id: "transfer" }, + chan_id_on_a: ChannelId { channel_id: "channel-0" }, + port_id_on_b: PortId { port_id: "transfer" }, + chan_id_on_b: ChannelId { channel_id: "channel-1" }, + data: serialized_data, + timeout_height_on_b: Height { revision_number: 0, revision_height: 1000 }, + timeout_timestamp_on_b: Timestamp { timestamp: 1000 } + } +} + +pub(crate) fn dummy_packet_data( + denom: PrefixedDenom, sender: ContractAddress, receiver: ContractAddress +) -> PacketData { + PacketData { denom, amount: AMOUNT, sender, receiver, memo: Memo { memo: "" }, } +} diff --git a/contracts/src/utils.cairo b/contracts/src/utils.cairo new file mode 100644 index 00000000..59065198 --- /dev/null +++ b/contracts/src/utils.cairo @@ -0,0 +1,137 @@ +/// Converts a generic type 'T' to a felt252. +pub trait ToFelt252Trait { + fn try_to_felt252(self: @T) -> Option; +} + +// Note: if bytes has leading `\0` (null char), `felt252` forgets that information. +// bytes_to_felt252(felt252_to_bytes(x)).unwrap() == x +pub fn bytes_to_felt252(bytes: @ByteArray) -> Option { + if bytes.len() == 0 { + return Option::Some(''); + } + + if bytes.len() > 31 { + return Option::None(()); + } + + let mut result: felt252 = 0; + let mut multiplier: felt252 = 1; + + // Iterate through the bytes in reverse order + let mut i = bytes.len(); + loop { + if i == 0 { + break; + } + i -= 1; + + let byte_value = bytes.at(i).unwrap(); + result += byte_value.into() * multiplier; + multiplier *= 0x100; // 256 + }; + + Option::Some(result) +} + +// felt252_to_bytes(bytes_to_felt252(x).unwrap()) == x +pub fn felt252_to_bytes(felt: felt252) -> ByteArray { + if felt == '' { + return ""; + } + + let mut result: ByteArray = ""; + let mut remaining: u256 = felt.into(); + + loop { + if remaining == 0 { + break; + } + + let byte_value = remaining % 0x100; // 256 + + result.append_byte(byte_value.try_into().unwrap()); + + remaining /= 0x100; // 256 + }; + + result.rev() +} + + +#[cfg(test)] +mod test { + use super::{bytes_to_felt252, felt252_to_bytes}; + + #[test] + fn test_empty_string() { + let bytes = ""; + let result = bytes_to_felt252(@bytes); + assert!(result == Option::Some(0), "Empty string should convert to 0"); + let result_back = felt252_to_bytes(result.unwrap()); + assert!(result_back == bytes, "Empty string should convert to 0"); + } + + #[test] + fn test_single_character() { + let bytes = "A"; + let result = bytes_to_felt252(@bytes); + assert!(result == Option::Some('A'), "Single character conversion failed"); + let result_back = felt252_to_bytes(result.unwrap()); + assert!(result_back == bytes, "Single character conversion failed"); + } + + #[test] + fn test_multiple_bytes() { + let bytes = "abc"; + let result = bytes_to_felt252(@bytes); + assert!(result == Option::Some('abc'), "Multiple byte conversion failed"); + let result_back = felt252_to_bytes(result.unwrap()); + assert!(result_back == bytes, "Multiple byte conversion failed"); + } + + #[test] + fn test_max_bytes() { + let bytes = "abcdefghijklmnopqrstuvwxyz12345"; // 31 characters + let result = bytes_to_felt252(@bytes); + assert!( + result == Option::Some('abcdefghijklmnopqrstuvwxyz12345'), "Max bytes conversion failed" + ); + let result_back = felt252_to_bytes(result.unwrap()); + assert!(result_back == bytes, "Max bytes conversion failed"); + } + + #[test] + fn test_too_many_bytes() { + let bytes = "abcdefghijklmnopqrstuvwxyz123456"; // 32 characters + let result = bytes_to_felt252(@bytes); + assert!(result == Option::None(()), "More than characters should return None"); + } + + #[test] + fn test_special_characters() { + let bytes = "!@#$"; + let result = bytes_to_felt252(@bytes); + assert!(result == Option::Some('!@#$'), "Special characters conversion failed"); + let result_back = felt252_to_bytes(result.unwrap()); + assert!(result_back == bytes, "Special characters conversion failed"); + } + + #[test] + fn test_null_character() { + let bytes = "abc\0def\0"; + let result = bytes_to_felt252(@bytes); + assert!(result == Option::Some('abc\0def\0'), "Null character conversion failed"); + let result_back = felt252_to_bytes(result.unwrap()); + assert!(result_back == bytes, "Null character conversion failed"); + } + + #[test] + fn test_leading_null_characters() { + let bytes = "\0\0\0abc"; + let result = bytes_to_felt252(@bytes); + assert!(result == Option::Some('\0\0\0abc'), "Leading null character conversion failed"); + let result_back = felt252_to_bytes(result.unwrap()); + // trims the leading null characters + assert!(result_back == "abc", "Leading null character conversion failed"); + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 00000000..7742b023 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,83 @@ +#!/bin/bash +set -euo pipefail + +source .env + +# use ggrep for macOS, and grep for Linux +case "$OSTYPE" in + darwin*) GREP="ggrep" ;; + linux-gnu*) GREP="grep" ;; + *) echo "Unknown OS: $OSTYPE" && exit 1 ;; +esac + +version() { + starkli --version 1>&2 + scarb --version 1>&2 +} + +# build the contract +build() { + version + + cd "$(dirname "$0")/../contracts" + + output=$(scarb build 2>&1) + + if [[ $output == *"Error"* ]]; then + echo "Error: $output" + exit 1 + fi +} + +# declare the contract +declare() { + build + + output=$( + starkli declare --watch $CONTRACT_SRC \ + --rpc $RPC_URL \ + --account $ACCOUNT_SRC \ + --keystore $KEYSTORE_SRC \ + --keystore-password $KEYSTORE_PASS \ + 2>&1 | tee /dev/tty + ) + + if [[ $output == *"Error"* ]]; then + echo "Error: $output" + exit 1 + fi + + address=$(echo -e "$output" | "$GREP" -oP '0x[0-9a-fA-F]+' | tail -n 1) + + echo $address +} + +# deploy the contract +deploy() { + if [[ $ICS20_CLASS_HASH == "" ]]; then + class_hash=$(declare) + else + class_hash=$CLASS_HASH + fi + + output=$( + starkli deploy --not-unique $ERC20_CLASS_HASH \ + --watch $class_hash \ + --rpc $RPC_URL \ + --account $ACCOUNT_SRC \ + --keystore $KEYSTORE_SRC \ + --keystore-password $KEYSTORE_PASS \ + 2>&1 | tee /dev/tty + ) + + if [[ $output == *"Error"* ]]; then + echo "Error: $output" + exit 1 + fi + + address=$(echo -e "$output" | "$GREP" -oP '0x[0-9a-fA-F]+' | tail -n 1) + + echo $address +} + +deploy diff --git a/scripts/invoke.sh b/scripts/invoke.sh new file mode 100755 index 00000000..d704d27e --- /dev/null +++ b/scripts/invoke.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +source ./scripts/deploy.sh + +# invoke the contract +invoke() { + if [[ $CONTRACT_ADDRESS == "" ]]; then + address=$(deploy) + else + address=$CONTRACT_ADDRESS + fi + + output=$( + starkli invoke $address send_exectue \ + --rpc $RPC_URL \ + --account $ACCOUNT_SRC \ + --keystore $KEYSTORE_SRC \ + --keystore-password $KEYSTORE_PASS \ + 2>&1 | tee /dev/tty + ) + + if [[ $output == *"Error"* ]]; then + echo "Error: $output" + exit 1 + fi + + echo $output +} + +invoke