diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c68479d59..36e69457b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: run: scarb fmt --check --workspace - name: Run tests - run: snforge test --workspace + run: snforge test --workspace --features fuzzing --fuzzer-runs 500 # Issue with cairo-coverage. Re-add to CI once issues are fixed. # diff --git a/packages/test_common/src/lib.cairo b/packages/test_common/src/lib.cairo index 55dcb574e..107445f0e 100644 --- a/packages/test_common/src/lib.cairo +++ b/packages/test_common/src/lib.cairo @@ -3,6 +3,7 @@ pub mod erc1155; pub mod erc20; pub mod erc721; pub mod eth_account; +pub mod math; pub mod mocks; pub mod ownable; pub mod upgrades; diff --git a/packages/test_common/src/math.cairo b/packages/test_common/src/math.cairo new file mode 100644 index 000000000..e832f01f7 --- /dev/null +++ b/packages/test_common/src/math.cairo @@ -0,0 +1,16 @@ +use core::num::traits::ops::overflowing::{OverflowingAdd, OverflowingMul, OverflowingSub}; + +pub fn is_overflow_add, +Drop>(x: T, y: T) -> bool { + let (_, does_overflow) = x.overflowing_add(y); + does_overflow +} + +pub fn is_overflow_mul, +Drop>(x: T, y: T) -> bool { + let (_, does_overflow) = x.overflowing_mul(y); + does_overflow +} + +pub fn is_overflow_sub, +Drop>(x: T, y: T) -> bool { + let (_, does_overflow) = x.overflowing_sub(y); + does_overflow +} diff --git a/packages/token/Scarb.toml b/packages/token/Scarb.toml index 6999f6322..e3c5d9d24 100644 --- a/packages/token/Scarb.toml +++ b/packages/token/Scarb.toml @@ -35,6 +35,9 @@ snforge_std.workspace = true openzeppelin_testing = { path = "../testing" } openzeppelin_test_common = { path = "../test_common" } +[features] +fuzzing = [] + [lib] [[target.starknet-contract]] diff --git a/packages/token/src/tests/erc20.cairo b/packages/token/src/tests/erc20.cairo index 841c19428..285ec1c3b 100644 --- a/packages/token/src/tests/erc20.cairo +++ b/packages/token/src/tests/erc20.cairo @@ -1,2 +1,5 @@ mod test_erc20; mod test_erc20_permit; + +#[cfg(feature: 'fuzzing')] +mod test_fuzz_erc20; diff --git a/packages/token/src/tests/erc20/test_fuzz_erc20.cairo b/packages/token/src/tests/erc20/test_fuzz_erc20.cairo new file mode 100644 index 000000000..dd87e06d1 --- /dev/null +++ b/packages/token/src/tests/erc20/test_fuzz_erc20.cairo @@ -0,0 +1,162 @@ +use ERC20Component::InternalTrait; +use core::num::traits::Bounded; +use crate::erc20::ERC20Component; +use crate::erc20::ERC20Component::{ERC20CamelOnlyImpl, ERC20Impl}; +use crate::erc20::ERC20Component::{ERC20MetadataImpl, InternalImpl}; +use openzeppelin_test_common::math::{is_overflow_add, is_overflow_sub}; +use openzeppelin_test_common::mocks::erc20::DualCaseERC20Mock; +use openzeppelin_testing::constants::{NAME, OWNER, RECIPIENT, SPENDER, SYMBOL}; +use snforge_std::{start_cheat_caller_address, test_address}; +use starknet::ContractAddress; + +// +// Setup +// + +type ComponentState = ERC20Component::ComponentState; + +fn COMPONENT_STATE() -> ComponentState { + ERC20Component::component_state_for_testing() +} + +fn setup(supply: u256) -> ComponentState { + let mut state = COMPONENT_STATE(); + state.initializer(NAME(), SYMBOL()); + state.mint(OWNER(), supply); + state +} + +// +// Tests +// + +#[test] +fn test_mint(supply: u256, mint_amount: u256) { + if is_overflow_add(supply, mint_amount) { + return; + } + let mut state = setup(supply); + + assert_total_supply(supply); + assert_balance(OWNER(), supply); + + state.mint(RECIPIENT(), mint_amount); + assert_total_supply(supply + mint_amount); + assert_balance(RECIPIENT(), mint_amount); +} + +#[test] +fn test_burn(supply: u256, burn_amount: u256) { + if is_overflow_sub(supply, burn_amount) { + return; + } + let mut state = setup(supply); + + assert_total_supply(supply); + assert_balance(OWNER(), supply); + + state.burn(OWNER(), burn_amount); + assert_total_supply(supply - burn_amount); + assert_balance(OWNER(), supply - burn_amount); +} + +#[test] +fn test_mint_burn(initial_supply: u256, mint_amount: u256, burn_amount: u256) { + if is_overflow_add(initial_supply, mint_amount) { + return; + } + if is_overflow_sub(mint_amount, burn_amount) { + return; + } + let mut state = setup(initial_supply); + let (owner, recipient) = (OWNER(), RECIPIENT()); + + // Mint + state.mint(recipient, mint_amount); + assert_total_supply(initial_supply + mint_amount); + assert_balance(owner, initial_supply); + assert_balance(recipient, mint_amount); + + // Burn + state.burn(recipient, burn_amount); + assert_total_supply(initial_supply + mint_amount - burn_amount); + assert_balance(owner, initial_supply); + assert_balance(recipient, mint_amount - burn_amount); +} + +#[test] +fn test_transfer(supply: u256, transfer_amount: u256) { + if is_overflow_sub(supply, transfer_amount) { + return; + } + let mut state = setup(supply); + let (owner, recipient) = (OWNER(), RECIPIENT()); + + start_cheat_caller_address(test_address(), owner); + state.transfer(recipient, transfer_amount); + + assert_balance(owner, supply - transfer_amount); + assert_balance(recipient, transfer_amount); +} + +#[test] +fn test_transfer_from(supply: u256, transfer_amount: u256) { + if is_overflow_sub(supply, transfer_amount) { + return; + } + let mut state = setup(supply); + let (owner, spender, recipient) = (OWNER(), SPENDER(), RECIPIENT()); + let contract_address = test_address(); + + // Approve + start_cheat_caller_address(contract_address, owner); + state.approve(spender, transfer_amount); + assert_balance(owner, supply); + assert_allowance(owner, spender, transfer_amount); + + // Transfer from + start_cheat_caller_address(contract_address, spender); + state.transfer_from(owner, recipient, transfer_amount); + assert_allowance(owner, spender, 0); + assert_balance(owner, supply - transfer_amount); + assert_balance(recipient, transfer_amount); + assert_balance(spender, 0); +} + +#[test] +fn test__spend_allowance(supply: u256, spend_amount: u256) { + if supply == Bounded::MAX { + return; + } + if is_overflow_sub(supply, spend_amount) { + return; + } + let mut state = setup(supply); + let (owner, spender) = (OWNER(), SPENDER()); + state._approve(owner, spender, supply); + + state._spend_allowance(owner, spender, spend_amount); + + assert_balance(owner, supply); + assert_balance(spender, 0); + assert_allowance(owner, spender, supply - spend_amount); +} + +// +// Helpers +// + +fn assert_total_supply(expected: u256) { + let state = COMPONENT_STATE(); + assert_eq!(state.total_supply(), expected); +} + +fn assert_allowance(owner: ContractAddress, spender: ContractAddress, expected: u256) { + let state = COMPONENT_STATE(); + assert_eq!(state.allowance(owner, spender), expected); +} + +fn assert_balance(owner: ContractAddress, expected: u256) { + let state = COMPONENT_STATE(); + assert_eq!(state.balance_of(owner), expected); +} diff --git a/packages/token/src/tests/erc721.cairo b/packages/token/src/tests/erc721.cairo index d42d6412a..057bba191 100644 --- a/packages/token/src/tests/erc721.cairo +++ b/packages/token/src/tests/erc721.cairo @@ -1,3 +1,6 @@ mod test_erc721; mod test_erc721_enumerable; mod test_erc721_receiver; + +#[cfg(feature: 'fuzzing')] +mod test_fuzz_erc721_enumerable; diff --git a/packages/token/src/tests/erc721/test_fuzz_erc721_enumerable.cairo b/packages/token/src/tests/erc721/test_fuzz_erc721_enumerable.cairo new file mode 100644 index 000000000..5c8539ec2 --- /dev/null +++ b/packages/token/src/tests/erc721/test_fuzz_erc721_enumerable.cairo @@ -0,0 +1,259 @@ +use crate::erc721::ERC721Component::{ERC721Impl, InternalImpl as ERC721InternalImpl}; +use crate::erc721::extensions::erc721_enumerable::ERC721EnumerableComponent; +use crate::erc721::extensions::erc721_enumerable::ERC721EnumerableComponent::{ + ERC721EnumerableImpl, InternalImpl, +}; +use openzeppelin_introspection::src5::SRC5Component::SRC5Impl; +use openzeppelin_test_common::mocks::erc721::ERC721EnumerableMock; +use openzeppelin_testing::constants::{OWNER, RECIPIENT}; +use starknet::ContractAddress; +use starknet::storage::StorageMapReadAccess; + +// +// Setup +// + +type ComponentState = + ERC721EnumerableComponent::ComponentState; + +fn CONTRACT_STATE() -> ERC721EnumerableMock::ContractState { + ERC721EnumerableMock::contract_state_for_testing() +} + +fn COMPONENT_STATE() -> ComponentState { + ERC721EnumerableComponent::component_state_for_testing() +} + +const MIN_SUPPLY: u32 = 2; +const MAX_SUPPLY: u32 = 100; + +fn prepare_supply(supply_seed: u32) -> u32 { + MIN_SUPPLY + supply_seed % (MAX_SUPPLY - MIN_SUPPLY + 1) +} + +fn setup(supply: u32) -> (ComponentState, Span) { + let mut state = COMPONENT_STATE(); + let mut mock_state = CONTRACT_STATE(); + state.initializer(); + + let mut tokens_list = array![]; + for i in 0..supply { + let token_id = 'TOKEN'.into() + i.into(); + mock_state.erc721.mint(OWNER(), token_id); + tokens_list.append(token_id); + }; + + (state, tokens_list.span()) +} + +// +// Tests +// + +#[test] +fn test_initial_state(supply_seed: u32) { + let supply = prepare_supply(supply_seed); + let (_, tokens_list) = setup(supply); + + assert_total_supply(supply.into()); + assert_token_by_index(tokens_list); + assert_token_of_owner_by_index(OWNER(), tokens_list); + assert_all_tokens_of_owner(OWNER(), tokens_list); +} + +#[test] +fn test_burn_token(supply_seed: u32, burn_index_seed: u32) { + let supply = prepare_supply(supply_seed); + let (_, initial_list) = setup(supply); + let mut state = CONTRACT_STATE(); + let burn_index = burn_index_seed % supply; + let token_to_burn = *initial_list.at(burn_index); + + state.erc721.burn(token_to_burn); + + let expected_list = remove_token_from_list(initial_list, token_to_burn); + assert_total_supply(supply - 1); + assert_token_by_index(expected_list); + assert_token_of_owner_by_index(OWNER(), expected_list); + assert_all_tokens_of_owner(OWNER(), expected_list); +} + +#[test] +fn test_transfer_single_token(supply_seed: u32, index_seed: u32) { + let supply = prepare_supply(supply_seed); + let (_, initial_list) = setup(supply); + let token_index = index_seed % supply; + let token_id = *initial_list.at(token_index); + let (owner, recipient) = (OWNER(), RECIPIENT()); + let mut state = CONTRACT_STATE(); + + state.erc721.transfer(owner, recipient, token_id); + + let expected_owner_tokens = remove_token_from_list(initial_list, token_id); + let expected_recipient_tokens = array![token_id].span(); + assert_total_supply(supply); + assert_token_by_index(initial_list); + assert_token_of_owner_by_index(owner, expected_owner_tokens); + assert_all_tokens_of_owner(owner, expected_owner_tokens); + assert_token_of_owner_by_index(recipient, expected_recipient_tokens); + assert_all_tokens_of_owner(recipient, expected_recipient_tokens); +} + +#[test] +fn test_transfer_two_tokens(supply_seed: u32, index_seed_1: u32, index_seed_2: u32) { + let supply = prepare_supply(supply_seed); + let token_index_1 = index_seed_1 % supply; + let token_index_2 = index_seed_2 % supply; + if token_index_1 == token_index_2 { + return; // Doesn't test transfer of a same token + } + let (_, initial_list) = setup(supply); + let token_1 = *initial_list.at(token_index_1); + let token_2 = *initial_list.at(token_index_2); + let (owner, recipient) = (OWNER(), RECIPIENT()); + let mut state = CONTRACT_STATE(); + + state.erc721.transfer(owner, recipient, token_1); + state.erc721.transfer(owner, recipient, token_2); + + let expected_owner_tokens = remove_token_from_list( + remove_token_from_list(initial_list, token_1), token_2, + ); + let expected_recipient_tokens = array![token_1, token_2].span(); + assert_total_supply(supply); + assert_token_by_index(initial_list); + assert_token_of_owner_by_index(owner, expected_owner_tokens); + assert_all_tokens_of_owner(owner, expected_owner_tokens); + assert_token_of_owner_by_index(recipient, expected_recipient_tokens); + assert_all_tokens_of_owner(recipient, expected_recipient_tokens); +} + +#[test] +fn test__add_token_to_owner_enumeration(supply_seed: u32, index_seed: u32) { + let supply = prepare_supply(supply_seed); + let (mut state, _) = setup(supply); + let new_token_index = supply; + let new_token_id = 'NEW_TOKEN'.into(); + + assert_owner_tokens_index_to_id(OWNER(), new_token_index, 0); + assert_owner_tokens_id_to_index(new_token_id, 0); + + state._add_token_to_owner_enumeration(OWNER(), new_token_id); + + assert_owner_tokens_index_to_id(OWNER(), new_token_index, new_token_id); + assert_owner_tokens_id_to_index(new_token_id, new_token_index); +} + +#[test] +fn test__add_token_to_all_tokens_enumeration(supply_seed: u32) { + let initial_supply = prepare_supply(supply_seed); + let (mut state, _) = setup(initial_supply); + let new_token_index = initial_supply; + let new_token_id = 'NEW_TOKEN'.into(); + + assert_all_tokens_index_to_id(new_token_index, 0); + assert_all_tokens_id_to_index(new_token_id, 0); + + state._add_token_to_all_tokens_enumeration(new_token_id); + + assert_all_tokens_index_to_id(new_token_index, new_token_id); + assert_all_tokens_id_to_index(new_token_id, new_token_index); + assert_total_supply(initial_supply + 1); +} + +// +// Helpers +// + +fn assert_total_supply(expected_supply: u32) { + let state = @COMPONENT_STATE(); + assert_eq!(state.total_supply(), expected_supply.into()); +} + +fn assert_token_of_owner_by_index(owner: ContractAddress, expected_token_list: Span) { + let state = @COMPONENT_STATE(); + let contract_state = @CONTRACT_STATE(); + + // Check owner balance == expected_token_list + let owner_bal = contract_state.balance_of(owner); + let expected_list_len = expected_token_list.len().into(); + assert_eq!(owner_bal, expected_list_len); + + for i in 0..expected_token_list.len() { + let token = state.token_of_owner_by_index(owner, i.into()); + assert_eq!(token, *expected_token_list.at(i)); + }; +} + +fn assert_token_by_index(expected_token_list: Span) { + let state = @COMPONENT_STATE(); + + // Check total_supply == expected_token_list + let total_supply = state.total_supply(); + let expected_list_len = expected_token_list.len().into(); + assert_eq!(total_supply, expected_list_len); + + for i in 0..expected_token_list.len() { + let token = state.token_by_index(i.into()); + assert_eq!(token, *expected_token_list.at(i)); + }; +} + +fn assert_all_tokens_index_to_id(index: u32, expected_token_id: u256) { + let state = @COMPONENT_STATE(); + let index_to_id = state.ERC721Enumerable_all_tokens.read(index.into()); + assert_eq!(index_to_id, expected_token_id); +} + +fn assert_all_tokens_id_to_index(token_id: u256, expected_index: u32) { + let state = @COMPONENT_STATE(); + let id_to_index = state.ERC721Enumerable_all_tokens_index.read(token_id); + assert_eq!(id_to_index, expected_index.into()); +} + +fn assert_owner_tokens_index_to_id(owner: ContractAddress, index: u32, expected_token_id: u256) { + let state = @COMPONENT_STATE(); + let index_to_id = state.ERC721Enumerable_owned_tokens.read((owner, index.into())); + assert_eq!(index_to_id, expected_token_id); +} + +fn assert_owner_tokens_id_to_index(token_id: u256, expected_index: u32) { + let state = @COMPONENT_STATE(); + let id_to_index = state.ERC721Enumerable_owned_tokens_index.read(token_id); + assert_eq!(id_to_index, expected_index.into()); +} + +fn assert_all_tokens_of_owner(owner: ContractAddress, expected_tokens: Span) { + let state = @COMPONENT_STATE(); + let tokens = state.all_tokens_of_owner(owner); + assert_eq!(tokens, expected_tokens); +} + +// Removes by swapping the token to remove with the last token to replicate +// ERC721Enumerable behaviour. Assumes that `tokens_list` contains no duplicates. +fn remove_token_from_list(tokens_list: Span, token_to_remove: u256) -> Span { + let last_index = tokens_list.len() - 1; + let mut index = 0; + let mut is_found = false; + let mut result = array![]; + while index <= last_index { + let token = *tokens_list.at(index); + if is_found { + if index != last_index { + result.append(token); + } + } else { + if token == token_to_remove { + is_found = true; + if index != last_index { + let last_token = tokens_list.at(last_index); + result.append(*last_token); + } + } else { + result.append(token); + } + } + index += 1; + }; + result.span() +} diff --git a/packages/utils/Scarb.toml b/packages/utils/Scarb.toml index 0454fc82d..a92584fd9 100644 --- a/packages/utils/Scarb.toml +++ b/packages/utils/Scarb.toml @@ -31,6 +31,9 @@ snforge_std.workspace = true openzeppelin_testing = { path = "../testing" } openzeppelin_test_common = { path = "../test_common" } +[features] +fuzzing = [] + [lib] [[target.starknet-contract]] diff --git a/packages/utils/src/tests.cairo b/packages/utils/src/tests.cairo index 597d6ddef..3c7b54dde 100644 --- a/packages/utils/src/tests.cairo +++ b/packages/utils/src/tests.cairo @@ -1,4 +1,6 @@ mod test_checkpoint; -mod test_math; + +#[cfg(feature: 'fuzzing')] +mod test_fuzz_math; mod test_nonces; mod test_snip12; diff --git a/packages/utils/src/tests/test_math.cairo b/packages/utils/src/tests/test_fuzz_math.cairo similarity index 100% rename from packages/utils/src/tests/test_math.cairo rename to packages/utils/src/tests/test_fuzz_math.cairo