diff --git a/cloakpay/Scarb.lock b/cloakpay/Scarb.lock index bed552f..a2a343e 100644 --- a/cloakpay/Scarb.lock +++ b/cloakpay/Scarb.lock @@ -5,9 +5,129 @@ version = 1 name = "cloakpay" version = "0.1.0" dependencies = [ + "openzeppelin", "snforge_std", ] +[[package]] +name = "openzeppelin" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:320185f3e17cf9fafda88b1ce490f5eaed0bfcc273036b56cd22ce4fb8de628f" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_governance", + "openzeppelin_introspection", + "openzeppelin_merkle_tree", + "openzeppelin_presets", + "openzeppelin_security", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_access" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:a39a4ea1582916c637bf7e3aee0832c3fe1ea3a3e39191955e8dc39d08327f9b" +dependencies = [ + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_account" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:7e943a2de32ddca4d48e467e52790e380ab1f49c4daddbbbc4634dd930d0243f" +dependencies = [ + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_finance" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:9fa9e91d39b6ccdfa31eef32fdc087cd06c0269cc9c6b86e32d57f5a6997d98b" +dependencies = [ + "openzeppelin_access", + "openzeppelin_token", +] + +[[package]] +name = "openzeppelin_governance" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:c05add2974b3193c3a5c022b9586a84cf98c5970cdb884dcf201c77dbe359f55" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_introspection" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:34e088ecf19e0b3012481a29f1fbb20e600540cb9a5db1c3002a97ebb7f5a32a" + +[[package]] +name = "openzeppelin_merkle_tree" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:a5341705514a3d9beeeb39cf11464111f7355be621639740d2c5006786aa63dc" + +[[package]] +name = "openzeppelin_presets" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:4eb098e2ee3ac0e67b6828115a7de62f781418beab767d4e80b54e176808369d" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_security" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:1deb811a239c4f9cc28fc302039e2ffcb19911698a8c612487207448d70d2e6e" + +[[package]] +name = "openzeppelin_token" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:33fcb84a1a76d2d3fff9302094ff564f78d45b743548fd7568c130b272473f66" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_upgrades" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:36f7a03e7e7111577916aacf31f88ad0053de20f33ee10b0ab3804849c3aa373" + +[[package]] +name = "openzeppelin_utils" +version = "1.0.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:fd348b31c4a4407add33adc3c2b8f26dca71dbd7431faaf726168f37a91db0c1" + [[package]] name = "snforge_scarb_plugin" version = "0.40.0" diff --git a/cloakpay/Scarb.toml b/cloakpay/Scarb.toml index 7079fb1..787f5f6 100644 --- a/cloakpay/Scarb.toml +++ b/cloakpay/Scarb.toml @@ -7,6 +7,7 @@ edition = "2024_07" [dependencies] starknet = "2.11.2" +openzeppelin = "1.0.0" [dev-dependencies] snforge_std = "0.40.0" diff --git a/cloakpay/src/base/errors.cairo b/cloakpay/src/base/errors.cairo index fc0d2a2..51a22d2 100644 --- a/cloakpay/src/base/errors.cairo +++ b/cloakpay/src/base/errors.cairo @@ -1 +1,9 @@ -pub mod Errors {} +pub mod CloakPayErrors { + pub const UNSUPPORTED_TOKEN: felt252 = 'UNSUPPORTED TOKEN'; + pub const COMMITMENT_ALREADY_USED: felt252 = 'COMMITMENT ALREADY USED'; +} + +pub mod payment_errors { + pub const INSUFFICIENT_ALLOWANCE: felt252 = 'Insufficient token allowance'; + pub const INSUFFICIENT_BALANCE: felt252 = 'Insufficient token balance'; +} diff --git a/cloakpay/src/base/events.cairo b/cloakpay/src/base/events.cairo index d11b95a..050cdd9 100644 --- a/cloakpay/src/base/events.cairo +++ b/cloakpay/src/base/events.cairo @@ -1 +1,11 @@ -pub mod Events {} +use crate::base::types::DepositDetails; + +pub mod Events { + use super::*; + #[derive(Drop, starknet::Event)] + pub struct DepositEvent { + #[key] + pub deposit_id: u256, + pub details: DepositDetails, + } +} diff --git a/cloakpay/src/base/token.cairo b/cloakpay/src/base/token.cairo new file mode 100644 index 0000000..43e9c64 --- /dev/null +++ b/cloakpay/src/base/token.cairo @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT + +/// @title STRK.cairo +/// @notice This file defines the STARKTOKEN contract, which implements the ERC20 token standard. +/// @dev The contract uses OpenZeppelin components for access control and ERC20 functionality. + +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IExternal { + /// @notice Mints new tokens to a specified recipient. + /// @param self The contract state. + /// @param recipient The address of the recipient who will receive the minted tokens. + /// @param amount The amount of tokens to mint. + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256); +} + +#[starknet::contract] +pub mod token { + use core::byte_array::ByteArray; + use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::token::erc20::interface::IERC20Metadata; + use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + + #[storage] + /// @notice Storage struct for the Predifi contract. + /// @dev Holds all pools, user stakes, odds, roles, and protocol parameters. + pub struct Storage { + #[substorage(v0)] + pub erc20: ERC20Component::Storage, + #[substorage(v0)] + pub ownable: OwnableComponent::Storage, + custom_decimals: u8, + token_name: ByteArray, + token_symbol: ByteArray, + } + + #[event] + #[derive(Drop, starknet::Event)] + /// @notice Events emitted by the Predifi contract. + enum Event { + #[flat] + ERC20Event: ERC20Component::Event, + #[flat] + OwnableEvent: OwnableComponent::Event, + } + + /// @notice Contract constructor. Initializes the ERC20 and Ownable components. + /// @param self The contract state. + /// @param recipient The address to receive the initial supply. + /// @param owner The address to be set as the contract owner. + /// @param decimals The number of decimals for the token. + #[constructor] + fn constructor( + ref self: ContractState, recipient: ContractAddress, owner: ContractAddress, decimals: u8, + ) { + let name: ByteArray = "STRK"; + let symbol: ByteArray = "STRK"; + // Initialize the ERC20 component + self.erc20.initializer(name, symbol); + self.ownable.initializer(owner); + self.custom_decimals.write(decimals); + self.erc20.mint(recipient, 200_000_000_000_000_000_000_000); + } + + #[abi(embed_v0)] + impl CustomERC20MetadataImpl of IERC20Metadata { + /// @notice Returns the name of the token. + /// @param self The contract state. + /// @return The name of the token. + fn name(self: @ContractState) -> ByteArray { + self.token_name.read() + } + + /// @notice Returns the symbol of the token. + /// @param self The contract state. + /// @return The symbol of the token. + fn symbol(self: @ContractState) -> ByteArray { + self.token_symbol.read() + } + + /// @notice Returns the number of decimals used to get its user representation. + /// @param self The contract state. + /// @return The number of decimals. + fn decimals(self: @ContractState) -> u8 { + self.custom_decimals.read() // Return custom value + } + } + + // Keep existing implementations + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableImpl; + impl InternalImpl = ERC20Component::InternalImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + #[abi(embed_v0)] + impl ExternalImpl of super::IExternal { + /// @notice Mints new tokens to a specified recipient. + /// @param self The contract state. + /// @param recipient The address of the recipient who will receive the minted tokens. + /// @param amount The amount of tokens to mint. + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { + self.erc20.mint(recipient, amount); + } + } +} diff --git a/cloakpay/src/base/types.cairo b/cloakpay/src/base/types.cairo index b9677fe..fa31a1a 100644 --- a/cloakpay/src/base/types.cairo +++ b/cloakpay/src/base/types.cairo @@ -1,3 +1,15 @@ -/// Common types used across the contract. - +use starknet::ContractAddress; +/// @notice enum of supported tokens +#[derive(Copy, Drop, Serde, PartialEq, starknet::Store, Debug)] +pub enum SupportedToken { + #[default] + STRK, +} +#[derive(Copy, Drop, Serde, PartialEq, starknet::Store, Debug)] +pub struct DepositDetails { + pub supported_token: ContractAddress, + pub amount: u256, + pub commitment: felt252, + pub time_sent: u64, +} diff --git a/cloakpay/src/cloakpay.cairo b/cloakpay/src/cloakpay.cairo index eec5fe3..d62e63e 100644 --- a/cloakpay/src/cloakpay.cairo +++ b/cloakpay/src/cloakpay.cairo @@ -1,21 +1,109 @@ #[starknet::contract] pub mod cloakpay { + use core::num::traits::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::{ + Map, StorageMapReadAccess, StoragePathEntry, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; + use crate::base::errors::{CloakPayErrors, payment_errors}; + use crate::base::events::Events::DepositEvent; + use crate::base::types::DepositDetails; use crate::interfaces::ICloakpay::ICloakPay; - use super::*; + #[storage] - struct Storage {} + struct Storage { + deposit: Map, // deposit id to deposit details + commitments: Map, // commitment to use status + commitment_to_deposit_id: Map, // commitment to deposit id + total_deposits: u256, + supported_tokens: Map // supported token_id to contract address + } #[event] #[derive(Drop, starknet::Event)] - pub enum Event {} + pub enum Event { + DepositEvent: DepositEvent, + } #[constructor] - fn constructor(ref self: ContractState) {} + fn constructor(ref self: ContractState, default_supported_token: ContractAddress) { + self.supported_tokens.entry(1_u256).write(default_supported_token); + self.total_deposits.write(0_u256); + } #[abi(embed_v0)] - impl CloakPayImpl of ICloakPay {} + impl CloakPayImpl of ICloakPay { + fn deposit( + ref self: ContractState, supported_token: u256, amount: u256, commitment: felt252, + ) { + assert(!self.commitments.read(commitment), CloakPayErrors::COMMITMENT_ALREADY_USED); + let supported_token = self.supported_tokens.read(supported_token); + assert(!supported_token.is_zero(), CloakPayErrors::UNSUPPORTED_TOKEN); + self._process_payment(amount, supported_token); + let deposit_id = self.total_deposits.read() + 1; + let deposit_details = DepositDetails { + supported_token, amount, commitment, time_sent: get_block_timestamp(), + }; + self.deposit.entry(deposit_id).write(deposit_details); + self.commitment_to_deposit_id.entry(commitment).write(deposit_id); + self.total_deposits.write(deposit_id); + self.commitments.entry(commitment).write(true); + self + .emit( + Event::DepositEvent( + DepositEvent { deposit_id: deposit_id, details: deposit_details }, + ), + ); + } + + fn get_deposit_details(ref self: ContractState, deposit_id: u256) -> DepositDetails { + self.deposit.read(deposit_id) + } + + fn get_total_deposits(ref self: ContractState) -> u256 { + self.total_deposits.read() + } + + fn get_commitment_used_status(ref self: ContractState, commitment: felt252) -> bool { + self.commitments.read(commitment) + } + + fn get_deposit_id_from_commitment(ref self: ContractState, commitment: felt252) -> u256 { + self.commitment_to_deposit_id.read(commitment) + } + } #[generate_trait] - impl Internal of InternalTrait {} + impl Internal of InternalTrait { + /// @notice Processes a payment for a deposit + fn _process_payment(ref self: ContractState, amount: u256, token: ContractAddress) { + let strk_token = IERC20Dispatcher { contract_address: token }; + let caller = get_caller_address(); + let contract_address = get_contract_address(); + self._check_token_allowance(caller, amount, token); + self._check_token_balance(caller, amount, token); + strk_token.transfer_from(caller, contract_address, amount); + } + + /// @notice Checks if the caller has sufficient token allowance. + fn _check_token_allowance( + ref self: ContractState, spender: ContractAddress, amount: u256, token: ContractAddress, + ) { + let token = IERC20Dispatcher { contract_address: token }; + let allowance = token.allowance(spender, starknet::get_contract_address()); + assert(allowance >= amount, payment_errors::INSUFFICIENT_ALLOWANCE); + } + + /// @notice Checks if the caller has sufficient token balance. + fn _check_token_balance( + ref self: ContractState, caller: ContractAddress, amount: u256, token: ContractAddress, + ) { + let token = IERC20Dispatcher { contract_address: token }; + let balance = token.balance_of(caller); + assert(balance >= amount, payment_errors::INSUFFICIENT_BALANCE); + } + } } diff --git a/cloakpay/src/interfaces/ICloakpay.cairo b/cloakpay/src/interfaces/ICloakpay.cairo index 846145b..a71500c 100644 --- a/cloakpay/src/interfaces/ICloakpay.cairo +++ b/cloakpay/src/interfaces/ICloakpay.cairo @@ -1,2 +1,10 @@ +use crate::base::types::DepositDetails; #[starknet::interface] -pub trait ICloakPay {} +pub trait ICloakPay { + /// @notice This function allows users to deposit supported ERC20 tokens into the mixer. + fn deposit(ref self: TContractState, supported_token: u256, amount: u256, commitment: felt252); + fn get_deposit_details(ref self: TContractState, deposit_id: u256) -> DepositDetails; + fn get_total_deposits(ref self: TContractState) -> u256; + fn get_commitment_used_status(ref self: TContractState, commitment: felt252) -> bool; + fn get_deposit_id_from_commitment(ref self: TContractState, commitment: felt252) -> u256; +} diff --git a/cloakpay/src/lib.cairo b/cloakpay/src/lib.cairo index ed15891..bde07c0 100644 --- a/cloakpay/src/lib.cairo +++ b/cloakpay/src/lib.cairo @@ -2,6 +2,7 @@ pub mod base { pub mod errors; pub mod events; pub mod security; + pub mod token; pub mod types; } diff --git a/cloakpay/tests/test_contract.cairo b/cloakpay/tests/test_contract.cairo index 8b13789..cdfee46 100644 --- a/cloakpay/tests/test_contract.cairo +++ b/cloakpay/tests/test_contract.cairo @@ -1 +1,147 @@ +use cloakpay::base::events::Events::DepositEvent; +use cloakpay::cloakpay::cloakpay::Event as cloakpayEvent; +use cloakpay::interfaces::ICloakpay::ICloakPayDispatcherTrait; +use openzeppelin::token::erc20::interface::IERC20DispatcherTrait; +use snforge_std::{ + EventSpyAssertionsTrait, spy_events, start_cheat_block_timestamp_global, + start_cheat_caller_address, stop_cheat_caller_address, +}; +use crate::test_utils::{deploy_cloakpay, owner, test_address_1, to_18_decimals}; +#[test] +fn test_create_deposit_successful() { + let (cloakpay_dispatcher, token_dispatcher) = deploy_cloakpay(); + + let mut spy = spy_events(); + + let token_address = token_dispatcher.contract_address; + + let cloakpay_address = cloakpay_dispatcher.contract_address; + + start_cheat_caller_address(token_address, owner); + + token_dispatcher.transfer(test_address_1, to_18_decimals(50)); + + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(token_address, test_address_1); + + token_dispatcher.approve(cloakpay_address, to_18_decimals(50)); + + stop_cheat_caller_address(token_address); + + // cheat timer + start_cheat_block_timestamp_global(12987); + + start_cheat_caller_address(cloakpay_address, test_address_1); + + cloakpay_dispatcher.deposit(1, to_18_decimals(50), 1443); + + stop_cheat_caller_address(token_address); + + // assert token was sent to us and token was removed form them + let cloakpay_balance = token_dispatcher.balance_of(cloakpay_address); + + let user_balance = token_dispatcher.balance_of(test_address_1); + + assert!(cloakpay_balance == to_18_decimals(50), "cloakpay balance incorrect"); + + assert!(user_balance == 0_u256, "user balance incorrect"); + + // assert deposit id and details weer stored + let deposit_details = cloakpay_dispatcher.get_deposit_details(1); + + assert!(deposit_details.amount == to_18_decimals(50), "deposit amount incorrect"); + + assert!(deposit_details.commitment == 1443, "deposit commitment incorrect"); + + assert!(deposit_details.supported_token == token_address, "deposit supported token incorrect"); + + assert!(deposit_details.time_sent == 12987, "deposit time sent incorrect"); + + // assert commitment was marked as used + let commitment_used = cloakpay_dispatcher.get_commitment_used_status(1443); + + assert!(commitment_used, "commitment used status incorrect"); + + // assert total deposits incremented + let total_deposits = cloakpay_dispatcher.get_total_deposits(); + + assert!(total_deposits == 1_u256, "total deposits incorrect"); + + // assert commitment to id was written + let deposit_id = cloakpay_dispatcher.get_deposit_id_from_commitment(1443); + + assert!(deposit_id == 1_u256, "deposit id from commitment incorrect"); + + println!("All assertions passed {:?}", deposit_details); + + // assert event was emmitted + let expected_event = cloakpayEvent::DepositEvent( + DepositEvent { deposit_id: 1_u256, details: deposit_details }, + ); + + spy.assert_emitted(@array![(cloakpay_address, expected_event)]); +} + + +#[test] +#[should_panic(expected: 'COMMITMENT ALREADY USED')] +fn test_create_deposit_should_panic_if_commitment_already_used() { + let (cloakpay_dispatcher, token_dispatcher) = deploy_cloakpay(); + + let token_address = token_dispatcher.contract_address; + + let cloakpay_address = cloakpay_dispatcher.contract_address; + + start_cheat_caller_address(token_address, owner); + + token_dispatcher.transfer(test_address_1, to_18_decimals(50)); + + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(token_address, test_address_1); + + token_dispatcher.approve(cloakpay_address, to_18_decimals(50)); + + stop_cheat_caller_address(token_address); + + // cheat timer + start_cheat_block_timestamp_global(12987); + + start_cheat_caller_address(cloakpay_address, test_address_1); + + cloakpay_dispatcher.deposit(1, to_18_decimals(25), 1443); + + cloakpay_dispatcher.deposit(1, to_18_decimals(25), 1443); +} + + +#[test] +#[should_panic(expected: 'UNSUPPORTED TOKEN')] +fn test_create_deposit_should_panic_if_unsupported_token() { + let (cloakpay_dispatcher, token_dispatcher) = deploy_cloakpay(); + + let token_address = token_dispatcher.contract_address; + + let cloakpay_address = cloakpay_dispatcher.contract_address; + + start_cheat_caller_address(token_address, owner); + + token_dispatcher.transfer(test_address_1, to_18_decimals(50)); + + stop_cheat_caller_address(token_address); + + start_cheat_caller_address(token_address, test_address_1); + + token_dispatcher.approve(cloakpay_address, to_18_decimals(50)); + + stop_cheat_caller_address(token_address); + + // cheat timer + start_cheat_block_timestamp_global(12987); + + start_cheat_caller_address(cloakpay_address, test_address_1); + + cloakpay_dispatcher.deposit(4, to_18_decimals(25), 1443); +} diff --git a/cloakpay/tests/test_utils.cairo b/cloakpay/tests/test_utils.cairo index 8b13789..28cd8e3 100644 --- a/cloakpay/tests/test_utils.cairo +++ b/cloakpay/tests/test_utils.cairo @@ -1 +1,35 @@ +use cloakpay::interfaces::ICloakpay::{ICloakPayDispatcher, ICloakPayDispatcherTrait}; +use openzeppelin::token::erc20::interface::IERC20Dispatcher; +use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; +use starknet::ContractAddress; + +pub const owner: ContractAddress = 'owner'.try_into().unwrap(); +pub const test_address_1: ContractAddress = 'test_address_1'.try_into().unwrap(); +pub const test_address_2: ContractAddress = 'test_address_2'.try_into().unwrap(); +pub const test_address_3: ContractAddress = 'test_address_3'.try_into().unwrap(); + + +pub fn deploy_cloakpay() -> (ICloakPayDispatcher, IERC20Dispatcher) { + let (erc20, erc20_address) = deploy_token(); + let cloakpay_class = declare("cloakpay").unwrap().contract_class(); + let (contract_address, _) = cloakpay_class.deploy(@array![erc20_address.into()]).unwrap(); + + (ICloakPayDispatcher { contract_address }, erc20) +} + +pub fn deploy_token() -> (IERC20Dispatcher, ContractAddress) { + let erc20_class = declare("token").unwrap().contract_class(); + let mut calldata = array![owner.into(), owner.into(), 6]; + let (erc20_address, _) = erc20_class.deploy(@calldata).unwrap(); + (IERC20Dispatcher { contract_address: erc20_address }, erc20_address) +} + +fn create_default_deposit(cloakpay_dispatcher: ICloakPayDispatcher) -> u256 { + cloakpay_dispatcher.deposit(1, 8000, 4443242); + 1 +} + +pub fn to_18_decimals(num: u256) -> u256 { + 1000000000000000000 * num +}