diff --git a/cloakpay/src/base/errors.cairo b/cloakpay/src/base/errors.cairo index 51a22d2..417a967 100644 --- a/cloakpay/src/base/errors.cairo +++ b/cloakpay/src/base/errors.cairo @@ -1,6 +1,7 @@ pub mod CloakPayErrors { pub const UNSUPPORTED_TOKEN: felt252 = 'UNSUPPORTED TOKEN'; pub const COMMITMENT_ALREADY_USED: felt252 = 'COMMITMENT ALREADY USED'; + pub const ERROR_ZERO_ADDRESS: felt252 = 'ERROR ZERO ADDRESS'; } pub mod payment_errors { diff --git a/cloakpay/src/base/events.cairo b/cloakpay/src/base/events.cairo index 050cdd9..19c77fc 100644 --- a/cloakpay/src/base/events.cairo +++ b/cloakpay/src/base/events.cairo @@ -1,3 +1,4 @@ +use starknet::ContractAddress; use crate::base::types::DepositDetails; pub mod Events { @@ -8,4 +9,26 @@ pub mod Events { pub deposit_id: u256, pub details: DepositDetails, } + + #[derive(Drop, starknet::Event)] + pub struct TokenAdded { + pub token_address: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct TokenRemoved { + pub token_address: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct Paused { + pub account: ContractAddress, + pub timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct Unpaused { + pub account: ContractAddress, + pub timestamp: u64, + } } diff --git a/cloakpay/src/cloakpay.cairo b/cloakpay/src/cloakpay.cairo index d62e63e..2fac640 100644 --- a/cloakpay/src/cloakpay.cairo +++ b/cloakpay/src/cloakpay.cairo @@ -1,35 +1,87 @@ #[starknet::contract] pub mod cloakpay { use core::num::traits::Zero; + use openzeppelin::access::accesscontrol::AccessControlComponent; + use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; use starknet::storage::{ - Map, StorageMapReadAccess, StoragePathEntry, StoragePointerReadAccess, - StoragePointerWriteAccess, + Map, MutableVecTrait, StorageMapReadAccess, StoragePathEntry, StoragePointerReadAccess, + StoragePointerWriteAccess, Vec, VecTrait, }; 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::events::Events::*; use crate::base::types::DepositDetails; use crate::interfaces::ICloakpay::ICloakPay; + const ADMIN_ROLE: felt252 = selector!("ADMIN"); + const OVERALL_ADMIN_ROLE: felt252 = selector!("OVERALL_ADMIN_ROLE"); + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + + #[abi(embed_v0)] + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; + impl InternalImpl = OwnableComponent::InternalImpl; + // AccessControl + #[abi(embed_v0)] + impl AccessControlImpl = + AccessControlComponent::AccessControlImpl; + impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; + + // SRC5 + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; #[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 + supported_tokens: Map, // supported token_id to contract address + supported_tokens_status: Map, // token address to supported status + supported_tokens_list: Vec, // list of supported token addresses + paused: bool, // contract paused status + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + accesscontrol: AccessControlComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, } #[event] #[derive(Drop, starknet::Event)] pub enum Event { DepositEvent: DepositEvent, + TokenAdded: TokenAdded, + TokenRemoved: TokenRemoved, + Paused: Paused, + Unpaused: Unpaused, + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + AccessControlEvent: AccessControlComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, } #[constructor] - fn constructor(ref self: ContractState, default_supported_token: ContractAddress) { + fn constructor( + ref self: ContractState, owner: ContractAddress, default_supported_token: ContractAddress, + ) { + assert(!owner.is_zero(), CloakPayErrors::ERROR_ZERO_ADDRESS); + // initialize owner of contract + self.ownable.initializer(owner); + self.accesscontrol.initializer(); + self.accesscontrol.set_role_admin(ADMIN_ROLE, OVERALL_ADMIN_ROLE); + self.accesscontrol._grant_role(ADMIN_ROLE, owner); + self.accesscontrol._grant_role(OVERALL_ADMIN_ROLE, owner); self.supported_tokens.entry(1_u256).write(default_supported_token); + self.supported_tokens_status.entry(default_supported_token).write(true); + self.supported_tokens_list.push(default_supported_token); self.total_deposits.write(0_u256); } @@ -39,6 +91,7 @@ pub mod cloakpay { fn deposit( ref self: ContractState, supported_token: u256, amount: u256, commitment: felt252, ) { + self._assert_not_paused(); 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); @@ -74,6 +127,66 @@ pub mod cloakpay { fn get_deposit_id_from_commitment(ref self: ContractState, commitment: felt252) -> u256 { self.commitment_to_deposit_id.read(commitment) } + + + fn pause(ref self: ContractState) { + self.accesscontrol.assert_only_role(ADMIN_ROLE); + self.paused.write(true); + self + .emit( + Event::Paused( + Paused { account: get_caller_address(), timestamp: get_block_timestamp() }, + ), + ); + } + fn resume(ref self: ContractState) { + self.accesscontrol.assert_only_role(ADMIN_ROLE); + + self.paused.write(false); + self + .emit( + Event::Unpaused( + Unpaused { + account: get_caller_address(), timestamp: get_block_timestamp(), + }, + ), + ); + } + fn get_paused_status(ref self: ContractState) -> bool { + self.paused.read() + } + + fn add_supported_token(ref self: ContractState, token_address: ContractAddress) { + self.accesscontrol.assert_only_role(ADMIN_ROLE); + assert(!token_address.is_zero(), CloakPayErrors::ERROR_ZERO_ADDRESS); + self._assert_not_paused(); + + assert(!self.supported_tokens_status.read(token_address), 'Token already supported'); + + self.supported_tokens_status.entry(token_address).write(true); + self.supported_tokens_list.push(token_address); + self.emit(Event::TokenAdded(TokenAdded { token_address })); + } + + fn remove_supported_token(ref self: ContractState, token_address: ContractAddress) { + self.accesscontrol.assert_only_role(ADMIN_ROLE); + assert(!token_address.is_zero(), CloakPayErrors::ERROR_ZERO_ADDRESS); + self._assert_not_paused(); + + assert(self.supported_tokens_status.read(token_address), 'Token not supported'); + + self.supported_tokens_status.entry(token_address).write(false); + self._remove_token_from_list(token_address); + self.emit(Event::TokenRemoved(TokenRemoved { token_address })); + } + + fn is_token_supported(ref self: ContractState, token_address: ContractAddress) -> bool { + self.supported_tokens_status.read(token_address) + } + + fn get_supported_tokens(ref self: ContractState) -> Array { + self._get_supported_tokens_array() + } } #[generate_trait] @@ -105,5 +218,45 @@ pub mod cloakpay { let balance = token.balance_of(caller); assert(balance >= amount, payment_errors::INSUFFICIENT_BALANCE); } + + /// @notice Asserts that the contract is not paused. + fn _assert_not_paused(ref self: ContractState) { + assert(!self.paused.read(), 'CONTRACT IS PAUSED'); + } + + /// @notice Gets all supported tokens as an array + fn _get_supported_tokens_array(self: @ContractState) -> Array { + let mut tokens = ArrayTrait::::new(); + let list_length = self.supported_tokens_list.len(); + for i in 0..list_length { + let token_address = self.supported_tokens_list.at(i).read(); + if self.supported_tokens_status.read(token_address) { + tokens.append(token_address); + } + } + tokens + } + + /// @notice Removes a token from the supported tokens list + fn _remove_token_from_list(ref self: ContractState, token_address: ContractAddress) { + let mut temp_list = ArrayTrait::::new(); + let list_length = self.supported_tokens_list.len(); + + for i in 0..list_length { + let current_token = self.supported_tokens_list.at(i).read(); + if current_token != token_address { + temp_list.append(current_token); + } + } + + for _ in 0..list_length { + let _ = self.supported_tokens_list.pop(); + } + + let temp_length = temp_list.len(); + for i in 0..temp_length { + self.supported_tokens_list.push(*temp_list.at(i)); + } + } } } diff --git a/cloakpay/src/interfaces/ICloakpay.cairo b/cloakpay/src/interfaces/ICloakpay.cairo index a71500c..2c6c126 100644 --- a/cloakpay/src/interfaces/ICloakpay.cairo +++ b/cloakpay/src/interfaces/ICloakpay.cairo @@ -1,10 +1,17 @@ +use starknet::ContractAddress; use crate::base::types::DepositDetails; #[starknet::interface] 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; + fn pause(ref self: TContractState); + fn resume(ref self: TContractState); + fn get_paused_status(ref self: TContractState) -> bool; + fn add_supported_token(ref self: TContractState, token_address: ContractAddress); + fn remove_supported_token(ref self: TContractState, token_address: ContractAddress); + fn is_token_supported(ref self: TContractState, token_address: ContractAddress) -> bool; + fn get_supported_tokens(ref self: TContractState) -> Array; } diff --git a/cloakpay/tests/test_contract.cairo b/cloakpay/tests/test_contract.cairo index cdfee46..350b146 100644 --- a/cloakpay/tests/test_contract.cairo +++ b/cloakpay/tests/test_contract.cairo @@ -1,4 +1,4 @@ -use cloakpay::base::events::Events::DepositEvent; +use cloakpay::base::events::Events::{DepositEvent, Paused, Unpaused}; use cloakpay::cloakpay::cloakpay::Event as cloakpayEvent; use cloakpay::interfaces::ICloakpay::ICloakPayDispatcherTrait; use openzeppelin::token::erc20::interface::IERC20DispatcherTrait; @@ -117,6 +117,39 @@ fn test_create_deposit_should_panic_if_commitment_already_used() { } +#[test] +#[should_panic(expected: 'CONTRACT IS PAUSED')] +fn test_create_deposit_should_panic_if_contract_paused() { + 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); + + start_cheat_caller_address(cloakpay_address, owner); + cloakpay_dispatcher.pause(); + stop_cheat_caller_address(cloakpay_address); + + cloakpay_dispatcher.deposit(1, to_18_decimals(25), 1443); +} + #[test] #[should_panic(expected: 'UNSUPPORTED TOKEN')] fn test_create_deposit_should_panic_if_unsupported_token() { @@ -145,3 +178,202 @@ fn test_create_deposit_should_panic_if_unsupported_token() { cloakpay_dispatcher.deposit(4, to_18_decimals(25), 1443); } + +#[test] +fn test_pause_contract() { + let (cloakpay_dispatcher, _token_dispatcher) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + let mut spy = spy_events(); + start_cheat_block_timestamp_global(12987); + start_cheat_caller_address(cloakpay_address, owner); + + cloakpay_dispatcher.pause(); + + let paused = cloakpay_dispatcher.get_paused_status(); + assert(paused, 'Contract should be paused'); + + cloakpay_dispatcher.pause(); + + let paused = cloakpay_dispatcher.get_paused_status(); + assert(paused, 'Contract should be paused'); + + spy + .assert_emitted( + @array![ + ( + cloakpay_address, + cloakpayEvent::Paused(Paused { account: owner, timestamp: 12987 }), + ), + ], + ); +} + + +#[test] +#[should_panic(expected: 'Caller is missing role')] +fn test_pause_contract_should_panic_if_non_admin_pauses() { + let (cloakpay_dispatcher, _token_dispatcher) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + start_cheat_block_timestamp_global(12987); + + start_cheat_caller_address(cloakpay_address, test_address_1); + + cloakpay_dispatcher.pause(); +} + +#[test] +fn test_pause_and_resume_contract() { + let (cloakpay_dispatcher, _token_dispatcher) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + let mut spy = spy_events(); + + start_cheat_caller_address(cloakpay_address, owner); + start_cheat_block_timestamp_global(12987); + + cloakpay_dispatcher.pause(); + + let paused = cloakpay_dispatcher.get_paused_status(); + assert(paused, 'Contract should be paused'); + cloakpay_dispatcher.resume(); + + let paused_after = cloakpay_dispatcher.get_paused_status(); + assert(!paused_after, 'Contract should not be paused'); + + spy + .assert_emitted( + @array![ + ( + cloakpay_address, + cloakpayEvent::Unpaused(Unpaused { account: owner, timestamp: 12987 }), + ), + ], + ); + stop_cheat_caller_address(cloakpay_address); +} + + +#[test] +#[should_panic(expected: 'Caller is missing role')] +fn test_pause_and_resume_contract_should_panic_if_non_admin_resumes() { + let (cloakpay_dispatcher, _token_dispatcher) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + start_cheat_block_timestamp_global(12987); + start_cheat_caller_address(cloakpay_address, owner); + + cloakpay_dispatcher.pause(); + + stop_cheat_caller_address(cloakpay_address); + + start_cheat_caller_address(cloakpay_address, test_address_1); + cloakpay_dispatcher.resume(); + stop_cheat_caller_address(cloakpay_address); +} + + +#[test] +fn test_add_supported_token() { + let (cloakpay_dispatcher, token_dispatcher) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + // Deploy a second token to add as supported + let (token2_dispatcher, token2_address) = crate::test_utils::deploy_token(); + + // Become owner + start_cheat_caller_address(cloakpay_address, owner); + + // Add the new token as supported + cloakpay_dispatcher.add_supported_token(token2_address); + + // Assert token is supported + let is_supported = cloakpay_dispatcher.is_token_supported(token2_address); + assert!(is_supported, "Token should be supported after adding"); + + // Get supported tokens list and check token2_address is present + let supported_tokens = cloakpay_dispatcher.get_supported_tokens(); + let mut found = false; + let len = supported_tokens.len(); + for i in 0..len { + if *supported_tokens.at(i) == token2_address { + found = true; + } + } + assert!(found, "Token2 address should be in supported tokens list"); + + stop_cheat_caller_address(cloakpay_address); +} + + +#[test] +#[should_panic(expected: 'Caller is missing role')] +fn test_add_supported_token_should_panic_if_non_admin_adds() { + let (cloakpay_dispatcher, _) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + let (_, token2_address) = crate::test_utils::deploy_token(); + + start_cheat_caller_address(cloakpay_address, test_address_1); + + cloakpay_dispatcher.add_supported_token(token2_address); + stop_cheat_caller_address(cloakpay_address); +} + + +#[test] +fn test_remove_supported_token() { + let (cloakpay_dispatcher, token_dispatcher) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + // Deploy a second token to add and then remove + let (token2_dispatcher, token2_address) = crate::test_utils::deploy_token(); + + // Become owner + start_cheat_caller_address(cloakpay_address, owner); + + // Add the new token as supported + cloakpay_dispatcher.add_supported_token(token2_address); + + // Assert token is supported + let is_supported = cloakpay_dispatcher.is_token_supported(token2_address); + assert!(is_supported, "Token should be supported after adding"); + + // Remove the token + cloakpay_dispatcher.remove_supported_token(token2_address); + + // Assert token is no longer supported + let is_supported_after = cloakpay_dispatcher.is_token_supported(token2_address); + assert!(!is_supported_after, "Token should not be supported after removal"); + + // Get supported tokens list and check token2_address is NOT present + let supported_tokens = cloakpay_dispatcher.get_supported_tokens(); + let mut found = false; + let len = supported_tokens.len(); + for i in 0..len { + if *supported_tokens.at(i) == token2_address { + found = true; + } + } + assert!(!found, "Token2 address should not be in supported tokens list after removal"); + + stop_cheat_caller_address(cloakpay_address); +} + + +#[test] +#[should_panic(expected: 'Caller is missing role')] +fn test_remove_supported_token_should_panic_if_non_admin_removes() { + let (cloakpay_dispatcher, _) = deploy_cloakpay(); + let cloakpay_address = cloakpay_dispatcher.contract_address; + + let (_, token2_address) = crate::test_utils::deploy_token(); + + start_cheat_caller_address(cloakpay_address, owner); + cloakpay_dispatcher.add_supported_token(token2_address); + stop_cheat_caller_address(cloakpay_address); + + start_cheat_caller_address(cloakpay_address, test_address_1); + cloakpay_dispatcher.remove_supported_token(token2_address); + stop_cheat_caller_address(cloakpay_address); +} diff --git a/cloakpay/tests/test_utils.cairo b/cloakpay/tests/test_utils.cairo index 28cd8e3..f52153c 100644 --- a/cloakpay/tests/test_utils.cairo +++ b/cloakpay/tests/test_utils.cairo @@ -13,7 +13,9 @@ 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(); + let (contract_address, _) = cloakpay_class + .deploy(@array![owner.into(), erc20_address.into()]) + .unwrap(); (ICloakPayDispatcher { contract_address }, erc20) }