From 89f4a77fcb0d2cce46b8c9825c0efe711fc9a875 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 15:54:29 +0100 Subject: [PATCH 01/28] Remove floating pragma --- packages/game-passes/contracts/GamePasses.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index ef32810193..61f9b2f330 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.19; +pragma solidity 0.8.26; import {AccessControlUpgradeable, ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import {ERC1155SupplyUpgradeable, ERC1155Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155SupplyUpgradeable.sol"; From a1b423df0ec9fded83bdc5085ca3d6b90cdcb1aa Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 15:59:24 +0100 Subject: [PATCH 02/28] Reorganize TokenConfig struct data order --- packages/game-passes/contracts/GamePasses.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 61f9b2f330..8abcf68558 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -68,10 +68,10 @@ contract SandboxPasses1155Upgradeable is struct TokenConfig { bool isConfigured; bool transferable; + address treasuryWallet; // specific treasury wallet for this token uint256 maxSupply; // 0 for open edition string metadata; uint256 maxPerWallet; // max tokens that can be minted per wallet - address treasuryWallet; // specific treasury wallet for this token uint256 totalMinted; // total tokens already minted mapping(address => uint256) mintedPerWallet; // track mints per wallet mapping(address => bool) transferWhitelist; // whitelist for transfers From 9355d7fad6112201c24fd31d379ef48c25066046 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 16:15:04 +0100 Subject: [PATCH 03/28] Adde event coumentation --- packages/game-passes/contracts/GamePasses.sol | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 8abcf68558..bc47590feb 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -33,8 +33,18 @@ contract SandboxPasses1155Upgradeable is // ============================================================= /// @notice Emitted when the base URI is updated. + /// @param caller Address that initiated the base URI update + /// @param oldURI Previous base URI value before the update + /// @param newURI New base URI value after the update event BaseURISet(address indexed caller, string oldURI, string newURI); /// @notice Emitted when a token is configured. + /// @param caller Address that initiated the token configuration + /// @param tokenId ID of the token being configured + /// @param transferable Whether the token can be transferred + /// @param maxSupply Maximum supply for this token (0 means unlimited) + /// @param maxPerWallet Maximum number of tokens a single wallet can mint (0 means unlimited) + /// @param metadata Token-specific metadata string + /// @param treasuryWallet Address where payments for this token will be sent event TokenConfigured( address indexed caller, uint256 indexed tokenId, @@ -45,6 +55,12 @@ contract SandboxPasses1155Upgradeable is address treasuryWallet ); /// @notice Emitted when a token configuration is updated. + /// @param caller Address that initiated the token configuration update + /// @param tokenId ID of the token being updated + /// @param maxSupply New maximum supply for this token (0 means unlimited) + /// @param maxPerWallet New maximum number of tokens a single wallet can mint (0 means unlimited) + /// @param metadata New token-specific metadata string + /// @param treasuryWallet New address where payments for this token will be sent event TokenConfigUpdated( address indexed caller, uint256 indexed tokenId, @@ -54,10 +70,21 @@ contract SandboxPasses1155Upgradeable is address treasuryWallet ); /// @notice Emitted when a token's transferability is updated. + /// @param caller Address that initiated the transferability update + /// @param tokenId ID of the token whose transferability was changed + /// @param transferable New transferability status (true = transferable, false = soulbound) event TransferabilityUpdated(address indexed caller, uint256 indexed tokenId, bool transferable); /// @notice Emitted when transfer whitelist is updated. + /// @param caller Address that initiated the whitelist update + /// @param tokenId ID of the token whose whitelist was updated + /// @param accounts Array of addresses that were added to or removed from the whitelist + /// @param allowed Whether the addresses were added to (true) or removed from (false) the whitelist event TransferWhitelistUpdated(address indexed caller, uint256 indexed tokenId, address[] accounts, bool allowed); /// @notice Emitted when tokens are recovered from the contract. + /// @param caller Address that initiated the token recovery + /// @param token Address of the ERC20 token being recovered + /// @param recipient Address receiving the recovered tokens + /// @param amount Amount of tokens recovered event TokensRecovered(address indexed caller, address token, address recipient, uint256 amount); // ============================================================= From 06689e3aa6e1727a4e768b93a12306332d45834d Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 16:17:36 +0100 Subject: [PATCH 04/28] Add event for owner change --- packages/game-passes/contracts/GamePasses.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index bc47590feb..774767ce32 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -86,6 +86,11 @@ contract SandboxPasses1155Upgradeable is /// @param recipient Address receiving the recovered tokens /// @param amount Amount of tokens recovered event TokensRecovered(address indexed caller, address token, address recipient, uint256 amount); + /// @notice Emitted when the owner is updated + /// @param caller Address that initiated the owner update + /// @param oldOwner Previous owner address before the update + /// @param newOwner New owner address after the update + event OwnerUpdated(address indexed caller, address oldOwner, address newOwner); // ============================================================= // Structs @@ -887,7 +892,9 @@ contract SandboxPasses1155Upgradeable is * @dev The owner may have special permissions outside of the role system */ function setOwner(address _newOwner) external onlyRole(ADMIN_ROLE) { + address oldOwner = _coreStorage().internalOwner; _coreStorage().internalOwner = _newOwner; + emit OwnerUpdated(_msgSender(), oldOwner, _newOwner); } /** From 5d279f1fb8b625584108ba6c8b18229c7dbcbf1c Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 16:25:04 +0100 Subject: [PATCH 05/28] Adhere to EIP-7201 --- packages/game-passes/contracts/GamePasses.sol | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 774767ce32..71d9021b10 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -144,6 +144,7 @@ contract SandboxPasses1155Upgradeable is // Storage - ERC7201 // ============================================================= + /// @custom:storage-location erc7201:sandbox.game-passes.storage.CoreStorage struct CoreStorage { // Base URI for computing {uri} string baseURI; @@ -159,33 +160,41 @@ contract SandboxPasses1155Upgradeable is } function _coreStorage() private pure returns (CoreStorage storage cs) { - bytes32 position = CORE_STORAGE_LOCATION; + bytes32 position = keccak256( + abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.CoreStorage"))) - 1) + ) & ~bytes32(uint256(0xff)); // solhint-disable-next-line no-inline-assembly assembly { cs.slot := position } } + /// @custom:storage-location erc7201:sandbox.game-passes.storage.UserStorage struct UserStorage { // Track nonces for replay protection mapping(address => uint256) nonces; } function _userStorage() private pure returns (UserStorage storage us) { - bytes32 position = USER_STORAGE_LOCATION; + bytes32 position = keccak256( + abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.UserStorage"))) - 1) + ) & ~bytes32(uint256(0xff)); // solhint-disable-next-line no-inline-assembly assembly { us.slot := position } } + /// @custom:storage-location erc7201:sandbox.game-passes.storage.TokenStorage struct TokenStorage { // Mapping of token configurations mapping(uint256 => TokenConfig) tokenConfigs; } function _tokenStorage() private pure returns (TokenStorage storage ts) { - bytes32 position = TOKEN_STORAGE_LOCATION; + bytes32 position = keccak256( + abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.TokenStorage"))) - 1) + ) & ~bytes32(uint256(0xff)); // solhint-disable-next-line no-inline-assembly assembly { ts.slot := position @@ -203,21 +212,6 @@ contract SandboxPasses1155Upgradeable is /// @dev The role that is allowed to consume tokens bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); - /** - * @dev Core storage layout using ERC7201 namespaced storage pattern - */ - bytes32 public constant CORE_STORAGE_LOCATION = keccak256("sandbox.game-passes.storage.CoreStorage"); - - /** - * @dev User storage layout using ERC7201 for nonces - */ - bytes32 public constant USER_STORAGE_LOCATION = keccak256("sandbox.game-passes.storage.UserStorage"); - - /** - * @dev Token config storage layout using ERC7201 - */ - bytes32 public constant TOKEN_STORAGE_LOCATION = keccak256("sandbox.game-passes.storage.TokenStorage"); - /// @dev EIP-712 domain typehash bytes32 public constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); From 01d99a01f0999a201d4c871f86efc20bd121d79e Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 19:32:51 +0100 Subject: [PATCH 06/28] Use calldata instead of memory --- packages/game-passes/contracts/GamePasses.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 71d9021b10..99d2032c43 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -688,7 +688,7 @@ contract SandboxPasses1155Upgradeable is uint256 mintId, uint256 mintAmount, uint256 deadline, - bytes memory signature + bytes calldata signature ) external whenNotPaused { TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnId]; if (!burnConfig.isConfigured) { @@ -801,7 +801,7 @@ contract SandboxPasses1155Upgradeable is bool transferable, uint256 maxSupply, uint256 maxPerWallet, - string memory metadata, + string calldata metadata, address treasuryWallet ) external onlyRole(ADMIN_ROLE) { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; @@ -839,7 +839,7 @@ contract SandboxPasses1155Upgradeable is uint256 tokenId, uint256 maxSupply, uint256 maxPerWallet, - string memory metadata, + string calldata metadata, address treasuryWallet ) external onlyRole(ADMIN_ROLE) { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; @@ -873,7 +873,7 @@ contract SandboxPasses1155Upgradeable is * @dev Reverts if: * - Caller doesn't have ADMIN_ROLE */ - function setBaseURI(string memory newBaseURI) external onlyRole(ADMIN_ROLE) { + function setBaseURI(string calldata newBaseURI) external onlyRole(ADMIN_ROLE) { CoreStorage storage cs = _coreStorage(); emit BaseURISet(_msgSender(), cs.baseURI, newBaseURI); cs.baseURI = newBaseURI; From e9ad2c67b16db9046e223e6110f6296eaa72ae02 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 19:36:15 +0100 Subject: [PATCH 07/28] Rename the contract --- packages/game-passes/contracts/GamePasses.sol | 4 ++-- packages/game-passes/test/GamePasses.test.ts | 2 +- packages/game-passes/test/fixtures/game-passes-fixture.ts | 8 +++----- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 99d2032c43..737ade2a04 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -14,11 +14,11 @@ import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/Messa import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** - * @title SandboxPasses1155Upgradeable + * @title GamePasses * @notice An upgradeable ERC1155 contract with AccessControl-based permissions, * supply tracking, forced burns, burn-and-mint, and EIP-2981 royalties. */ -contract SandboxPasses1155Upgradeable is +contract GamePasses is Initializable, ERC2771HandlerUpgradeable, AccessControlUpgradeable, diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index aed23f74ef..407540d177 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -3,7 +3,7 @@ import {expect} from 'chai'; import {ethers, upgrades} from 'hardhat'; import {runCreateTestSetup} from './fixtures/game-passes-fixture'; -describe('SandboxPasses1155Upgradeable', function () { +describe('GamePasses', function () { describe('Initialization', function () { it('should initialize with correct values', async function () { const { diff --git a/packages/game-passes/test/fixtures/game-passes-fixture.ts b/packages/game-passes/test/fixtures/game-passes-fixture.ts index ae4c1118ab..50e3134506 100644 --- a/packages/game-passes/test/fixtures/game-passes-fixture.ts +++ b/packages/game-passes/test/fixtures/game-passes-fixture.ts @@ -1,7 +1,7 @@ import {SignerWithAddress} from '@nomicfoundation/hardhat-ethers/signers'; import {AddressLike, BigNumberish, BytesLike} from 'ethers'; import {ethers, upgrades} from 'hardhat'; -import {MockERC20, SandboxPasses1155Upgradeable} from '../../typechain-types'; +import {GamePasses, MockERC20} from '../../typechain-types'; export async function runCreateTestSetup() { const DOMAIN_NAME = 'SandboxPasses1155'; @@ -195,9 +195,7 @@ export async function runCreateTestSetup() { await paymentToken.mint(user2.address, ethers.parseEther('1000')); // Deploy the contract using upgrades plugin - const SandboxPasses = await ethers.getContractFactory( - 'SandboxPasses1155Upgradeable', - ); + const SandboxPasses = await ethers.getContractFactory('GamePasses'); const sandboxPasses = (await upgrades.deployProxy(SandboxPasses, [ BASE_URI, royaltyReceiver.address, @@ -209,7 +207,7 @@ export async function runCreateTestSetup() { trustedForwarder.address, treasury.address, owner.address, - ])) as unknown as SandboxPasses1155Upgradeable; + ])) as unknown as GamePasses; await sandboxPasses.waitForDeployment(); // Set up default token configuration From 31e52d1a1a889c8a4fddf46186409cde191e947a Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 19:48:31 +0100 Subject: [PATCH 08/28] Fix function and layout order --- packages/game-passes/contracts/GamePasses.sol | 290 +++++++++--------- 1 file changed, 145 insertions(+), 145 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 737ade2a04..2ea39db546 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -28,70 +28,6 @@ contract GamePasses is { using Strings for uint256; - // ============================================================= - // Events - // ============================================================= - - /// @notice Emitted when the base URI is updated. - /// @param caller Address that initiated the base URI update - /// @param oldURI Previous base URI value before the update - /// @param newURI New base URI value after the update - event BaseURISet(address indexed caller, string oldURI, string newURI); - /// @notice Emitted when a token is configured. - /// @param caller Address that initiated the token configuration - /// @param tokenId ID of the token being configured - /// @param transferable Whether the token can be transferred - /// @param maxSupply Maximum supply for this token (0 means unlimited) - /// @param maxPerWallet Maximum number of tokens a single wallet can mint (0 means unlimited) - /// @param metadata Token-specific metadata string - /// @param treasuryWallet Address where payments for this token will be sent - event TokenConfigured( - address indexed caller, - uint256 indexed tokenId, - bool transferable, - uint256 maxSupply, - uint256 maxPerWallet, - string metadata, - address treasuryWallet - ); - /// @notice Emitted when a token configuration is updated. - /// @param caller Address that initiated the token configuration update - /// @param tokenId ID of the token being updated - /// @param maxSupply New maximum supply for this token (0 means unlimited) - /// @param maxPerWallet New maximum number of tokens a single wallet can mint (0 means unlimited) - /// @param metadata New token-specific metadata string - /// @param treasuryWallet New address where payments for this token will be sent - event TokenConfigUpdated( - address indexed caller, - uint256 indexed tokenId, - uint256 maxSupply, - uint256 maxPerWallet, - string metadata, - address treasuryWallet - ); - /// @notice Emitted when a token's transferability is updated. - /// @param caller Address that initiated the transferability update - /// @param tokenId ID of the token whose transferability was changed - /// @param transferable New transferability status (true = transferable, false = soulbound) - event TransferabilityUpdated(address indexed caller, uint256 indexed tokenId, bool transferable); - /// @notice Emitted when transfer whitelist is updated. - /// @param caller Address that initiated the whitelist update - /// @param tokenId ID of the token whose whitelist was updated - /// @param accounts Array of addresses that were added to or removed from the whitelist - /// @param allowed Whether the addresses were added to (true) or removed from (false) the whitelist - event TransferWhitelistUpdated(address indexed caller, uint256 indexed tokenId, address[] accounts, bool allowed); - /// @notice Emitted when tokens are recovered from the contract. - /// @param caller Address that initiated the token recovery - /// @param token Address of the ERC20 token being recovered - /// @param recipient Address receiving the recovered tokens - /// @param amount Amount of tokens recovered - event TokensRecovered(address indexed caller, address token, address recipient, uint256 amount); - /// @notice Emitted when the owner is updated - /// @param caller Address that initiated the owner update - /// @param oldOwner Previous owner address before the update - /// @param newOwner New owner address after the update - event OwnerUpdated(address indexed caller, address oldOwner, address newOwner); - // ============================================================= // Structs // ============================================================= @@ -241,6 +177,70 @@ contract GamePasses is /// @dev Maximum number of tokens that can be processed in a batch operation uint256 public constant MAX_BATCH_SIZE = 100; + // ============================================================= + // Events + // ============================================================= + + /// @notice Emitted when the base URI is updated. + /// @param caller Address that initiated the base URI update + /// @param oldURI Previous base URI value before the update + /// @param newURI New base URI value after the update + event BaseURISet(address indexed caller, string oldURI, string newURI); + /// @notice Emitted when a token is configured. + /// @param caller Address that initiated the token configuration + /// @param tokenId ID of the token being configured + /// @param transferable Whether the token can be transferred + /// @param maxSupply Maximum supply for this token (0 means unlimited) + /// @param maxPerWallet Maximum number of tokens a single wallet can mint (0 means unlimited) + /// @param metadata Token-specific metadata string + /// @param treasuryWallet Address where payments for this token will be sent + event TokenConfigured( + address indexed caller, + uint256 indexed tokenId, + bool transferable, + uint256 maxSupply, + uint256 maxPerWallet, + string metadata, + address treasuryWallet + ); + /// @notice Emitted when a token configuration is updated. + /// @param caller Address that initiated the token configuration update + /// @param tokenId ID of the token being updated + /// @param maxSupply New maximum supply for this token (0 means unlimited) + /// @param maxPerWallet New maximum number of tokens a single wallet can mint (0 means unlimited) + /// @param metadata New token-specific metadata string + /// @param treasuryWallet New address where payments for this token will be sent + event TokenConfigUpdated( + address indexed caller, + uint256 indexed tokenId, + uint256 maxSupply, + uint256 maxPerWallet, + string metadata, + address treasuryWallet + ); + /// @notice Emitted when a token's transferability is updated. + /// @param caller Address that initiated the transferability update + /// @param tokenId ID of the token whose transferability was changed + /// @param transferable New transferability status (true = transferable, false = soulbound) + event TransferabilityUpdated(address indexed caller, uint256 indexed tokenId, bool transferable); + /// @notice Emitted when transfer whitelist is updated. + /// @param caller Address that initiated the whitelist update + /// @param tokenId ID of the token whose whitelist was updated + /// @param accounts Array of addresses that were added to or removed from the whitelist + /// @param allowed Whether the addresses were added to (true) or removed from (false) the whitelist + event TransferWhitelistUpdated(address indexed caller, uint256 indexed tokenId, address[] accounts, bool allowed); + /// @notice Emitted when tokens are recovered from the contract. + /// @param caller Address that initiated the token recovery + /// @param token Address of the ERC20 token being recovered + /// @param recipient Address receiving the recovered tokens + /// @param amount Amount of tokens recovered + event TokensRecovered(address indexed caller, address token, address recipient, uint256 amount); + /// @notice Emitted when the owner is updated + /// @param caller Address that initiated the owner update + /// @param oldOwner Previous owner address before the update + /// @param newOwner New owner address after the update + event OwnerUpdated(address indexed caller, address oldOwner, address newOwner); + // ============================================================= // Errors // ============================================================= @@ -964,17 +964,6 @@ contract GamePasses is return _coreStorage().baseURI; } - /** - * @notice Returns the metadata URI for a specific token ID - * @param tokenId ID of the token to get URI for - * @dev Constructs the URI by concatenating baseURI + tokenId + ".json" - * @dev Can be overridden by derived contracts to implement different URI logic - * @return string The complete URI for the token metadata - */ - function uri(uint256 tokenId) public view virtual override returns (string memory) { - return string(abi.encodePacked(_coreStorage().baseURI, tokenId.toString(), ".json")); - } - /** * @notice Returns the default treasury wallet address * @return address The default treasury wallet address @@ -1090,6 +1079,17 @@ contract GamePasses is emit TokensRecovered(_msgSender(), token, to, amount); } + /** + * @notice Returns the metadata URI for a specific token ID + * @param tokenId ID of the token to get URI for + * @dev Constructs the URI by concatenating baseURI + tokenId + ".json" + * @dev Can be overridden by derived contracts to implement different URI logic + * @return string The complete URI for the token metadata + */ + function uri(uint256 tokenId) public view virtual override returns (string memory) { + return string(abi.encodePacked(_coreStorage().baseURI, tokenId.toString(), ".json")); + } + /** * @notice Check if a token exists (has been configured) * @param tokenId The token ID to check @@ -1207,6 +1207,76 @@ contract GamePasses is // Private and Internal Functions // ============================================================= + /** + * @notice Internal hook to enforce transfer restrictions on soulbound tokens + * @param from Source address + * @param to Destination address + * @param ids Array of token IDs being transferred + * @param values Array of transfer amounts + * @dev Called on all ERC1155 transfers (mint, burn, or user transfer) + * @dev Enforces transferability rules: + * - Allows mints (from == address(0)) + * - Allows burns (to == address(0)) + * - Checks transferability for regular transfers + * @dev Reverts if: + * - Token is non-transferable AND + * - Sender is not whitelisted AND + * - Sender is not ADMIN_ROLE or OPERATOR_ROLE + */ + function _update( + address from, + address to, + uint256[] memory ids, + uint256[] memory values + ) internal virtual override(ERC1155SupplyUpgradeable) whenNotPaused { + // If not a mint (from == address(0)) and not a burn (to == address(0)), enforce transferability + if (from != address(0) && to != address(0)) { + bool isAdminOrOperator = hasRole(ADMIN_ROLE, _msgSender()) || hasRole(OPERATOR_ROLE, _msgSender()); + if (!isAdminOrOperator) { + for (uint256 i; i < ids.length; i++) { + uint256 tokenId = ids[i]; + TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; + + if (!config.transferable && !config.transferWhitelist[from]) { + revert TransferNotAllowed(tokenId); + } + } + } + } + + super._update(from, to, ids, values); + } + + /** + * @notice Gets the sender address, supporting meta-transactions + * @dev Overrides Context's _msgSender to support meta-transactions via ERC2771 + * @return address The sender's address (original sender for meta-transactions) + */ + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771HandlerUpgradeable) + returns (address) + { + return ERC2771HandlerUpgradeable._msgSender(); + } + + /** + * @notice Gets the transaction data, supporting meta-transactions + * @dev Overrides Context's _msgData to support meta-transactions via ERC2771 + * @return bytes calldata The transaction data (modified for meta-transactions) + */ + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771HandlerUpgradeable) + returns (bytes calldata) + { + return ERC2771HandlerUpgradeable._msgData(); + } + /** * @dev Internal helper function to process a single mint operation * @param caller The address calling the mint function @@ -1318,74 +1388,4 @@ contract GamePasses is revert ExceedsMaxPerWallet(tokenId, to, amount, config.maxPerWallet); } } - - /** - * @notice Internal hook to enforce transfer restrictions on soulbound tokens - * @param from Source address - * @param to Destination address - * @param ids Array of token IDs being transferred - * @param values Array of transfer amounts - * @dev Called on all ERC1155 transfers (mint, burn, or user transfer) - * @dev Enforces transferability rules: - * - Allows mints (from == address(0)) - * - Allows burns (to == address(0)) - * - Checks transferability for regular transfers - * @dev Reverts if: - * - Token is non-transferable AND - * - Sender is not whitelisted AND - * - Sender is not ADMIN_ROLE or OPERATOR_ROLE - */ - function _update( - address from, - address to, - uint256[] memory ids, - uint256[] memory values - ) internal virtual override(ERC1155SupplyUpgradeable) whenNotPaused { - // If not a mint (from == address(0)) and not a burn (to == address(0)), enforce transferability - if (from != address(0) && to != address(0)) { - bool isAdminOrOperator = hasRole(ADMIN_ROLE, _msgSender()) || hasRole(OPERATOR_ROLE, _msgSender()); - if (!isAdminOrOperator) { - for (uint256 i; i < ids.length; i++) { - uint256 tokenId = ids[i]; - TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; - - if (!config.transferable && !config.transferWhitelist[from]) { - revert TransferNotAllowed(tokenId); - } - } - } - } - - super._update(from, to, ids, values); - } - - /** - * @notice Gets the sender address, supporting meta-transactions - * @dev Overrides Context's _msgSender to support meta-transactions via ERC2771 - * @return address The sender's address (original sender for meta-transactions) - */ - function _msgSender() - internal - view - virtual - override(ContextUpgradeable, ERC2771HandlerUpgradeable) - returns (address) - { - return ERC2771HandlerUpgradeable._msgSender(); - } - - /** - * @notice Gets the transaction data, supporting meta-transactions - * @dev Overrides Context's _msgData to support meta-transactions via ERC2771 - * @return bytes calldata The transaction data (modified for meta-transactions) - */ - function _msgData() - internal - view - virtual - override(ContextUpgradeable, ERC2771HandlerUpgradeable) - returns (bytes calldata) - { - return ERC2771HandlerUpgradeable._msgData(); - } } From a64d3eb8cbf136b1f8c229b0437dfaee9062c496 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 19:51:42 +0100 Subject: [PATCH 09/28] Added named parameters in mappings --- packages/game-passes/contracts/GamePasses.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 2ea39db546..0f90b1b69d 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -41,8 +41,8 @@ contract GamePasses is string metadata; uint256 maxPerWallet; // max tokens that can be minted per wallet uint256 totalMinted; // total tokens already minted - mapping(address => uint256) mintedPerWallet; // track mints per wallet - mapping(address => bool) transferWhitelist; // whitelist for transfers + mapping(address owner => uint256 mintedCount) mintedPerWallet; // track mints per wallet + mapping(address caller => bool isWhitelisted) transferWhitelist; // whitelist for transfers } /// @dev Struct to hold burn and mint request @@ -108,7 +108,7 @@ contract GamePasses is /// @custom:storage-location erc7201:sandbox.game-passes.storage.UserStorage struct UserStorage { // Track nonces for replay protection - mapping(address => uint256) nonces; + mapping(address caller => uint256 nonce) nonces; } function _userStorage() private pure returns (UserStorage storage us) { @@ -124,7 +124,7 @@ contract GamePasses is /// @custom:storage-location erc7201:sandbox.game-passes.storage.TokenStorage struct TokenStorage { // Mapping of token configurations - mapping(uint256 => TokenConfig) tokenConfigs; + mapping(uint256 tokenId => TokenConfig tokenConfig) tokenConfigs; } function _tokenStorage() private pure returns (TokenStorage storage ts) { From d271d0369d2aff57449ceb1620131a7a0afef03e Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 19:54:51 +0100 Subject: [PATCH 10/28] Add security contact --- packages/game-passes/contracts/GamePasses.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 0f90b1b69d..2481078378 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -17,6 +17,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol * @title GamePasses * @notice An upgradeable ERC1155 contract with AccessControl-based permissions, * supply tracking, forced burns, burn-and-mint, and EIP-2981 royalties. + * @custom:security-contact contact-blockchain@sandbox.game */ contract GamePasses is Initializable, From a81765f12d319848ccff7ec3efe2c2f201bb4474 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 19:57:28 +0100 Subject: [PATCH 11/28] Limit function visibility --- packages/game-passes/contracts/GamePasses.sol | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 2481078378..00d130fcea 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -316,7 +316,7 @@ contract GamePasses is address _trustedForwarder, address _defaultTreasury, address _owner - ) public initializer { + ) external initializer { __ERC2771Handler_init(_trustedForwarder); __AccessControl_init(); __ERC1155_init(_baseURI); @@ -1062,6 +1062,15 @@ contract GamePasses is _unpause(); } + /** + * @notice Returns the current owner address of the contract + * @dev This address may have special permissions beyond role-based access control + * @return address The current owner address + */ + function owner() external view returns (address) { + return _coreStorage().internalOwner; + } + /** * @notice Recover ERC20 tokens accidentally sent to the contract * @param token The ERC20 token address to recover @@ -1182,15 +1191,6 @@ contract GamePasses is _verifySignature(structHash, signature, request.deadline); } - /** - * @notice Returns the current owner address of the contract - * @dev This address may have special permissions beyond role-based access control - * @return address The current owner address - */ - function owner() public view returns (address) { - return _coreStorage().internalOwner; - } - /** * @notice Checks if contract implements various interfaces * @param interfaceId The interface identifier to check From 47b5bafdf2e5cccb3e5b7e01a29f3e898c9e5f87 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 20:04:09 +0100 Subject: [PATCH 12/28] Remove named return --- packages/game-passes/contracts/GamePasses.sol | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 00d130fcea..3549c44952 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -1003,19 +1003,7 @@ contract GamePasses is */ function tokenConfigs( uint256 tokenId - ) - external - view - returns ( - bool isConfigured, - bool transferable, - uint256 maxSupply, - string memory metadata, - uint256 maxPerWallet, - address treasuryWallet, - uint256 totalMinted - ) - { + ) external view returns (bool, bool, uint256, string memory, uint256, address, uint256) { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; return ( config.isConfigured, From b9a4f9b58002e357afa06d18cdd06f52b74cd71e Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Mon, 24 Mar 2025 20:34:27 +0100 Subject: [PATCH 13/28] Update tests --- packages/game-passes/test/GamePasses.test.ts | 35 ++++++++++---------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 407540d177..98f0bbf1f6 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -57,11 +57,11 @@ describe('GamePasses', function () { // Check token configuration const tokenConfig = await sandboxPasses.tokenConfigs(TOKEN_ID_1); - expect(tokenConfig.isConfigured).to.be.true; - expect(tokenConfig.transferable).to.be.true; - expect(tokenConfig.maxSupply).to.equal(MAX_SUPPLY); - expect(tokenConfig.metadata).to.equal(TOKEN_METADATA); - expect(tokenConfig.maxPerWallet).to.equal(MAX_PER_WALLET); + expect(tokenConfig[0]).to.be.true; + expect(tokenConfig[1]).to.be.true; + expect(tokenConfig[2]).to.equal(MAX_SUPPLY); + expect(tokenConfig[3]).to.equal(TOKEN_METADATA); + expect(tokenConfig[4]).to.equal(MAX_PER_WALLET); }); }); @@ -95,12 +95,13 @@ describe('GamePasses', function () { ); const tokenConfig = await sandboxPasses.tokenConfigs(NEW_TOKEN_ID); - expect(tokenConfig.isConfigured).to.be.true; - expect(tokenConfig.transferable).to.be.true; - expect(tokenConfig.maxSupply).to.equal(200); - expect(tokenConfig.maxPerWallet).to.equal(20); - expect(tokenConfig.metadata).to.equal('ipfs://QmNewToken'); - expect(tokenConfig.treasuryWallet).to.equal(user1.address); + expect(tokenConfig[0]).to.be.true; + expect(tokenConfig[1]).to.be.true; + expect(tokenConfig[2]).to.equal(200); + expect(tokenConfig[3]).to.equal('ipfs://QmNewToken'); + expect(tokenConfig[4]).to.equal(20); + expect(tokenConfig[5]).to.equal(user1.address); + expect(tokenConfig[6]).to.equal(0); }); it('should not allow non-admin to configure a token', async function () { @@ -156,10 +157,10 @@ describe('GamePasses', function () { ); const tokenConfig = await sandboxPasses.tokenConfigs(TOKEN_ID_1); - expect(tokenConfig.maxSupply).to.equal(200); - expect(tokenConfig.maxPerWallet).to.equal(15); - expect(tokenConfig.metadata).to.equal('ipfs://QmUpdated'); - expect(tokenConfig.treasuryWallet).to.equal(user2.address); + expect(tokenConfig[2]).to.equal(200); + expect(tokenConfig[4]).to.equal(15); + expect(tokenConfig[3]).to.equal('ipfs://QmUpdated'); + expect(tokenConfig[5]).to.equal(user2.address); }); it('should not allow decreasing max supply below current supply', async function () { @@ -200,7 +201,7 @@ describe('GamePasses', function () { .withArgs(admin.address, TOKEN_ID_1, false); const tokenConfig = await sandboxPasses.tokenConfigs(TOKEN_ID_1); - expect(tokenConfig.transferable).to.be.false; + expect(tokenConfig[1]).to.be.false; // Change non-transferable token to transferable await expect( @@ -210,7 +211,7 @@ describe('GamePasses', function () { .withArgs(admin.address, TOKEN_ID_2, true); const tokenConfig2 = await sandboxPasses.tokenConfigs(TOKEN_ID_2); - expect(tokenConfig2.transferable).to.be.true; + expect(tokenConfig2[1]).to.be.true; }); it('should not allow setting transferability to the same value', async function () { From 304b2ce66994536c4d387904605e382cd0e04cce Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Tue, 25 Mar 2025 15:18:58 +0100 Subject: [PATCH 14/28] Use signature id instead of sequential nonce --- packages/game-passes/contracts/GamePasses.sol | 212 ++++++------ packages/game-passes/test/GamePasses.test.ts | 308 ++++++++---------- .../test/fixtures/game-passes-fixture.ts | 20 +- 3 files changed, 247 insertions(+), 293 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 3549c44952..8e799d04de 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -54,7 +54,7 @@ contract GamePasses is uint256 mintId; uint256 mintAmount; uint256 deadline; - uint256 nonce; + uint256 signatureId; } /// @dev Struct to hold mint request @@ -64,7 +64,7 @@ contract GamePasses is uint256 amount; uint256 price; uint256 deadline; - uint256 nonce; + uint256 signatureId; } /// @dev Struct to hold batch mint request @@ -74,7 +74,7 @@ contract GamePasses is uint256[] amounts; uint256[] prices; uint256 deadline; - uint256 nonce; + uint256 signatureId; } // ============================================================= @@ -89,8 +89,10 @@ contract GamePasses is address defaultTreasuryWallet; // Payment token address paymentToken; - // Owner + // Owner of the contract address internalOwner; + // map used to mark if a specific signatureId was used + mapping(uint256 signatureId => bool used) signatureIds; // EIP-712 domain separator // solhint-disable-next-line var-name-mixedcase bytes32 DOMAIN_SEPARATOR; @@ -106,22 +108,6 @@ contract GamePasses is } } - /// @custom:storage-location erc7201:sandbox.game-passes.storage.UserStorage - struct UserStorage { - // Track nonces for replay protection - mapping(address caller => uint256 nonce) nonces; - } - - function _userStorage() private pure returns (UserStorage storage us) { - bytes32 position = keccak256( - abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.UserStorage"))) - 1) - ) & ~bytes32(uint256(0xff)); - // solhint-disable-next-line no-inline-assembly - assembly { - us.slot := position - } - } - /// @custom:storage-location erc7201:sandbox.game-passes.storage.TokenStorage struct TokenStorage { // Mapping of token configurations @@ -160,19 +146,19 @@ contract GamePasses is /// @dev EIP-712 mint request typehash bytes32 public constant MINT_TYPEHASH = keccak256( - "MintRequest(address caller,uint256 tokenId,uint256 amount,uint256 price,uint256 deadline,uint256 nonce)" + "MintRequest(address caller,uint256 tokenId,uint256 amount,uint256 price,uint256 deadline,uint256 signatureId)" ); /// @dev EIP-712 burn and mint request typehash bytes32 public constant BURN_AND_MINT_TYPEHASH = keccak256( - "BurnAndMintRequest(address caller,uint256 burnId,uint256 burnAmount,uint256 mintId,uint256 mintAmount,uint256 deadline,uint256 nonce)" + "BurnAndMintRequest(address caller,uint256 burnId,uint256 burnAmount,uint256 mintId,uint256 mintAmount,uint256 deadline,uint256 signatureId)" ); /// @dev EIP-712 batch mint request typehash bytes32 public constant BATCH_MINT_TYPEHASH = keccak256( - "BatchMintRequest(address caller,uint256[] tokenIds,uint256[] amounts,uint256[] prices,uint256 deadline,uint256 nonce)" + "BatchMintRequest(address caller,uint256[] tokenIds,uint256[] amounts,uint256[] prices,uint256 deadline,uint256 signatureId)" ); /// @dev Maximum number of tokens that can be processed in a batch operation @@ -262,6 +248,8 @@ contract GamePasses is error InvalidSignature(ECDSA.RecoverError error); /// @dev Revert when invalid signer error InvalidSigner(); + /// @dev Revert when signature already used + error SignatureAlreadyUsed(uint256 signatureId); /// @dev Revert when max supply below current supply error MaxSupplyBelowCurrentSupply(uint256 tokenId); /// @dev Revert when transfer not allowed @@ -387,10 +375,18 @@ contract GamePasses is uint256 amount, uint256 price, uint256 deadline, - bytes calldata signature + bytes calldata signature, + uint256 signatureId ) external whenNotPaused { - _processSingleMint(caller, tokenId, amount, price, deadline, signature); - _mint(caller, tokenId, amount, ""); + MintRequest memory request = MintRequest({ + caller: caller, + tokenId: tokenId, + amount: amount, + price: price, + deadline: deadline, + signatureId: signatureId + }); + _processSingleMint(request, signature); } /** @@ -420,50 +416,18 @@ contract GamePasses is uint256[] calldata amounts, uint256[] calldata prices, uint256 deadline, - bytes calldata signature + bytes calldata signature, + uint256 signatureId ) external whenNotPaused { - if (tokenIds.length > MAX_BATCH_SIZE) { - revert BatchSizeExceeded(tokenIds.length, MAX_BATCH_SIZE); - } - - if (tokenIds.length != amounts.length || amounts.length != prices.length) { - revert ArrayLengthMismatch(); - } - BatchMintRequest memory request = BatchMintRequest({ caller: caller, tokenIds: tokenIds, amounts: amounts, prices: prices, deadline: deadline, - nonce: _userStorage().nonces[caller]++ + signatureId: signatureId }); - - verifyBatchSignature(request, signature); - - // Process each mint separately - for (uint256 i; i < tokenIds.length; i++) { - TokenConfig storage config = _tokenStorage().tokenConfigs[tokenIds[i]]; - - if (!config.isConfigured) { - revert TokenNotConfigured(tokenIds[i]); - } - - _checkMaxPerWallet(tokenIds[i], caller, amounts[i]); - _checkMaxSupply(tokenIds[i], amounts[i]); - - // Update minted amount for wallet - config.mintedPerWallet[caller] += amounts[i]; - - address treasury = config.treasuryWallet; - if (treasury == address(0)) { - treasury = _coreStorage().defaultTreasuryWallet; - } - SafeERC20.safeTransferFrom(IERC20(_coreStorage().paymentToken), caller, treasury, prices[i]); - } - - // Perform batch mint - _mintBatch(caller, tokenIds, amounts, ""); + _processBatchMint(request, signature); } /** @@ -689,7 +653,8 @@ contract GamePasses is uint256 mintId, uint256 mintAmount, uint256 deadline, - bytes calldata signature + bytes calldata signature, + uint256 signatureId ) external whenNotPaused { TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnId]; if (!burnConfig.isConfigured) { @@ -709,7 +674,7 @@ contract GamePasses is mintId: mintId, mintAmount: mintAmount, deadline: deadline, - nonce: _userStorage().nonces[caller]++ + signatureId: signatureId }); verifyBurnAndMintSignature(request, signature); @@ -982,12 +947,12 @@ contract GamePasses is } /** - * @notice Returns current nonce for a user address - * @param user The address to get nonce for - * @return uint256 The current nonce + * @notice Returns current status for a signatureId + * @param signatureId The signatureId to get status for + * @return bool The status of the signatureId, true if used, false otherwise */ - function getNonce(address user) external view returns (uint256) { - return _userStorage().nonces[user]; + function getSignatureStatus(uint256 signatureId) external view returns (bool) { + return _coreStorage().signatureIds[signatureId]; } /** @@ -1108,7 +1073,7 @@ contract GamePasses is * - Signature is invalid * - Signer doesn't have SIGNER_ROLE */ - function verifySignature(MintRequest memory request, bytes memory signature) public view { + function verifySignature(MintRequest memory request, bytes memory signature) public { bytes32 structHash = keccak256( abi.encode( MINT_TYPEHASH, @@ -1117,11 +1082,11 @@ contract GamePasses is request.amount, request.price, request.deadline, - request.nonce + request.signatureId ) ); - _verifySignature(structHash, signature, request.deadline); + _verifySignature(structHash, signature, request.deadline, request.signatureId); } /** @@ -1135,7 +1100,7 @@ contract GamePasses is * - Signature is invalid * - Signer doesn't have SIGNER_ROLE */ - function verifyBurnAndMintSignature(BurnAndMintRequest memory request, bytes memory signature) public view { + function verifyBurnAndMintSignature(BurnAndMintRequest memory request, bytes memory signature) public { bytes32 structHash = keccak256( abi.encode( BURN_AND_MINT_TYPEHASH, @@ -1145,11 +1110,11 @@ contract GamePasses is request.mintId, request.mintAmount, request.deadline, - request.nonce + request.signatureId ) ); - _verifySignature(structHash, signature, request.deadline); + _verifySignature(structHash, signature, request.deadline, request.signatureId); } /** @@ -1163,7 +1128,7 @@ contract GamePasses is * - Signature is invalid * - Signer doesn't have SIGNER_ROLE */ - function verifyBatchSignature(BatchMintRequest memory request, bytes memory signature) public view { + function verifyBatchSignature(BatchMintRequest memory request, bytes memory signature) public { bytes32 structHash = keccak256( abi.encode( BATCH_MINT_TYPEHASH, @@ -1172,11 +1137,11 @@ contract GamePasses is keccak256(abi.encodePacked(request.amounts)), keccak256(abi.encodePacked(request.prices)), request.deadline, - request.nonce + request.signatureId ) ); - _verifySignature(structHash, signature, request.deadline); + _verifySignature(structHash, signature, request.deadline, request.signatureId); } /** @@ -1268,49 +1233,76 @@ contract GamePasses is /** * @dev Internal helper function to process a single mint operation - * @param caller The address calling the mint function - * @param tokenId The token ID to mint - * @param amount The amount to mint - * @param price The price to pay - * @param deadline The signature deadline + * @param request The MintRequest struct containing all mint parameters * @param signature The EIP-712 signature */ - function _processSingleMint( - address caller, - uint256 tokenId, - uint256 amount, - uint256 price, - uint256 deadline, - bytes calldata signature - ) private { - TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; + function _processSingleMint(MintRequest memory request, bytes calldata signature) private { + TokenConfig storage config = _tokenStorage().tokenConfigs[request.tokenId]; if (!config.isConfigured) { - revert TokenNotConfigured(tokenId); + revert TokenNotConfigured(request.tokenId); } - MintRequest memory request = MintRequest({ - caller: caller, - tokenId: tokenId, - amount: amount, - price: price, - deadline: deadline, - nonce: _userStorage().nonces[caller]++ - }); - verifySignature(request, signature); - _checkMaxPerWallet(tokenId, caller, amount); - _checkMaxSupply(tokenId, amount); + _checkMaxPerWallet(request.tokenId, request.caller, request.amount); + _checkMaxSupply(request.tokenId, request.amount); // Update minted amount for wallet - config.mintedPerWallet[caller] += amount; + config.mintedPerWallet[request.caller] += request.amount; address treasury = config.treasuryWallet; if (treasury == address(0)) { treasury = _coreStorage().defaultTreasuryWallet; } - SafeERC20.safeTransferFrom(IERC20(_coreStorage().paymentToken), caller, treasury, price); + SafeERC20.safeTransferFrom(IERC20(_coreStorage().paymentToken), request.caller, treasury, request.price); + _mint(request.caller, request.tokenId, request.amount, ""); + } + + /** + * @dev Internal helper function to process batch minting + * @param request The BatchMintRequest struct containing all batch mint parameters + * @param signature The EIP-712 signature + */ + function _processBatchMint(BatchMintRequest memory request, bytes calldata signature) private { + if (request.tokenIds.length > MAX_BATCH_SIZE) { + revert BatchSizeExceeded(request.tokenIds.length, MAX_BATCH_SIZE); + } + + if (request.tokenIds.length != request.amounts.length || request.amounts.length != request.prices.length) { + revert ArrayLengthMismatch(); + } + + verifyBatchSignature(request, signature); + + // Process each mint separately + for (uint256 i; i < request.tokenIds.length; i++) { + TokenConfig storage config = _tokenStorage().tokenConfigs[request.tokenIds[i]]; + + if (!config.isConfigured) { + revert TokenNotConfigured(request.tokenIds[i]); + } + + _checkMaxPerWallet(request.tokenIds[i], request.caller, request.amounts[i]); + _checkMaxSupply(request.tokenIds[i], request.amounts[i]); + + // Update minted amount for wallet + config.mintedPerWallet[request.caller] += request.amounts[i]; + + address treasury = config.treasuryWallet; + if (treasury == address(0)) { + treasury = _coreStorage().defaultTreasuryWallet; + } + SafeERC20.safeTransferFrom( + IERC20(_coreStorage().paymentToken), + request.caller, + treasury, + request.prices[i] + ); + } + + // Perform batch mint + _mintBatch(request.caller, request.tokenIds, request.amounts, ""); } /** @@ -1324,11 +1316,17 @@ contract GamePasses is * - Signature is invalid or malformed * - Signer doesn't have SIGNER_ROLE */ - function _verifySignature(bytes32 hash, bytes memory signature, uint256 deadline) private view { + function _verifySignature(bytes32 hash, bytes memory signature, uint256 deadline, uint256 signatureId) private { if (block.timestamp > deadline) { revert SignatureExpired(); } + if (_coreStorage().signatureIds[signatureId]) { + revert SignatureAlreadyUsed(signatureId); + } + + _coreStorage().signatureIds[signatureId] = true; + bytes32 finalHash = MessageHashUtils.toTypedDataHash(_coreStorage().DOMAIN_SEPARATOR, hash); (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(finalHash, signature); diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 98f0bbf1f6..3a62054e67 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -536,7 +536,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; // 1 hour from now - const nonce = 0; // First transaction for user + const signatureId = 12345; // First transaction for user // Approve payment token await paymentToken @@ -551,7 +551,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Mint with signature @@ -564,6 +564,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ); expect(await sandboxPasses.balanceOf(user1.address, TOKEN_ID_1)).to.equal( @@ -589,7 +590,7 @@ describe('GamePasses', function () { const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; // 1 hour from now - const nonce = 0; // First transaction for user + const signatureId = 12345; // First transaction for user const signature = await createMintSignature( signer, @@ -598,7 +599,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); await expect( @@ -609,6 +610,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ]), ).to.not.be.reverted; }); @@ -629,7 +631,7 @@ describe('GamePasses', function () { const price1 = ethers.parseEther('0.1'); const price2 = ethers.parseEther('0.2'); const deadline = (await time.latest()) + 3600; // 1 hour from now - const nonce1 = 0; + const signatureId = 12345; // First transaction for user // Approve payment token await paymentToken @@ -637,7 +639,6 @@ describe('GamePasses', function () { .approve(await sandboxPasses.getAddress(), price1 + price2); // Create signatures - const signature = await createBatchMintSignature( signer, user1.address, @@ -645,7 +646,7 @@ describe('GamePasses', function () { [MINT_AMOUNT, MINT_AMOUNT * 2], [price1, price2], deadline, - nonce1, + signatureId, ); await expect( @@ -656,6 +657,7 @@ describe('GamePasses', function () { [price1, price2], deadline, signature, + signatureId, ]), ).to.not.be.reverted; }); @@ -676,7 +678,7 @@ describe('GamePasses', function () { const price1 = ethers.parseEther('0.1'); const price2 = ethers.parseEther('0.2'); const deadline = (await time.latest()) + 3600; // 1 hour from now - const nonce1 = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -691,7 +693,7 @@ describe('GamePasses', function () { [MINT_AMOUNT, MINT_AMOUNT * 2], [price1, price2], deadline, - nonce1, + signatureId, ); // Batch mint with signatures @@ -704,6 +706,7 @@ describe('GamePasses', function () { [price1, price2], deadline, signature, + signatureId, ); expect(await sandboxPasses.balanceOf(user1.address, TOKEN_ID_1)).to.equal( @@ -730,7 +733,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) - 3600; // 1 hour in the past - const nonce = 0; + const signatureId = 12345; // Create signature const signature = await createMintSignature( @@ -740,7 +743,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint with expired signature @@ -754,6 +757,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'SignatureExpired'); }); @@ -769,7 +773,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature from unauthorized user const signature = await createMintSignature( @@ -779,7 +783,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint with invalid signature @@ -793,6 +797,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -809,7 +814,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -824,7 +829,7 @@ describe('GamePasses', function () { MAX_PER_WALLET + 1, price, deadline, - nonce, + signatureId, ); // Try to mint more than max per wallet @@ -838,6 +843,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'ExceedsMaxPerWallet'); }); @@ -855,12 +861,12 @@ describe('GamePasses', function () { const MAX_BATCH_SIZE = 100; // Match the contract's constant const price = ethers.parseEther('0.01'); const deadline = (await time.latest()) + 3600; + const signatureId = 12345; // Configure tokens (we need 100 configured tokens) const tokenIds = []; const amounts = []; const prices = []; - const nonce = 0; // First configure all needed tokens for (let i = 4; i < MAX_BATCH_SIZE + 4; i++) { @@ -886,7 +892,7 @@ describe('GamePasses', function () { amounts, prices, deadline, - nonce, + signatureId, ); // Approve payment token for the whole batch @@ -907,6 +913,7 @@ describe('GamePasses', function () { prices, deadline, signature, + signatureId, ); // Verify a few tokens were minted successfully @@ -931,12 +938,12 @@ describe('GamePasses', function () { const EXCEEDED_SIZE = MAX_BATCH_SIZE + 1; const price = ethers.parseEther('0.01'); const deadline = (await time.latest()) + 3600; + const signatureId = 12345; // Configure tokens (we need 101 configured tokens) const tokenIds = []; const amounts = []; const prices = []; - const nonce = 0; // First configure all needed tokens, start from 4 as previous tokens have been configured for (let i = 4; i < EXCEEDED_SIZE + 4; i++) { @@ -963,7 +970,7 @@ describe('GamePasses', function () { amounts, prices, deadline, - nonce, + signatureId, ); // Approve payment token for the whole batch @@ -985,160 +992,85 @@ describe('GamePasses', function () { prices, deadline, signature, + signatureId, ), ) .to.be.revertedWithCustomError(sandboxPasses, 'BatchSizeExceeded') .withArgs(EXCEEDED_SIZE, MAX_BATCH_SIZE); }); - it('should not allow minting with incorrect nonce', async function () { + // New test to check that you can't mint with the same signature ID twice + it('should not allow reusing the same signatureId', async function () { const { sandboxPasses, signer, user1, paymentToken, TOKEN_ID_1, + TOKEN_ID_2, MINT_AMOUNT, - createMintSignature, + createBatchMintSignature, } = await loadFixture(runCreateTestSetup); - const price = ethers.parseEther('0.1'); - const deadline = (await time.latest()) + 3600; // 1 hour from now - const incorrectNonce = 1; // User's nonce should be 0 initially - - // Approve payment token - await paymentToken - .connect(user1) - .approve(await sandboxPasses.getAddress(), price); - - // Create signature with incorrect nonce - const signature = await createMintSignature( - signer, - user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, - deadline, - incorrectNonce, - ); - // Try to mint with incorrect nonce - await expect( - sandboxPasses - .connect(user1) - .mint( - user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, - deadline, - signature, - ), - ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); // invalid signer because the hash was incorrect due to bad nonce - }); - - it('should not allow replay attacks by reusing signatures', async function () { - const { - sandboxPasses, - signer, - user1, - paymentToken, - TOKEN_ID_1, - MINT_AMOUNT, - createMintSignature, - } = await loadFixture(runCreateTestSetup); - const price = ethers.parseEther('0.1'); - const deadline = (await time.latest()) + 3600; // 1 hour from now - const nonce = 0; // First transaction for user + const price1 = ethers.parseEther('0.1'); + const price2 = ethers.parseEther('0.2'); + const deadline = (await time.latest()) + 3600; + const signatureId = 12345; // Approve payment token for two transactions await paymentToken .connect(user1) - .approve(await sandboxPasses.getAddress(), price * 2n); + .approve(await sandboxPasses.getAddress(), (price1 + price2) * 2n); // Create signature - const signature = await createMintSignature( + const signature = await createBatchMintSignature( signer, user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, + [TOKEN_ID_1, TOKEN_ID_2], + [MINT_AMOUNT, MINT_AMOUNT], + [price1, price2], deadline, - nonce, + signatureId, ); // First mint should succeed await sandboxPasses .connect(user1) - .mint( + .batchMint( user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, + [TOKEN_ID_1, TOKEN_ID_2], + [MINT_AMOUNT, MINT_AMOUNT], + [price1, price2], deadline, signature, + signatureId, ); - // Second mint with same signature should fail (replay attack) - await expect( - sandboxPasses - .connect(user1) - .mint( - user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, - deadline, - signature, - ), - ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); // invalid signer because the hash was incorrect due nonce in sig and contract mismatch - }); - - it('should increment user nonce after successful mint', async function () { - const { - sandboxPasses, - signer, - user1, - paymentToken, - TOKEN_ID_1, - MINT_AMOUNT, - createMintSignature, - } = await loadFixture(runCreateTestSetup); - const price = ethers.parseEther('0.1'); - const deadline = (await time.latest()) + 3600; // 1 hour from now - - // Check initial nonce - expect(await sandboxPasses.getNonce(user1.address)).to.equal(0); - - // Approve payment token - await paymentToken - .connect(user1) - .approve(await sandboxPasses.getAddress(), price); - - // Create signature with correct nonce - const signature = await createMintSignature( + // Create another signature with the same signatureId but different tokens/amounts + const signature2 = await createBatchMintSignature( signer, user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, + [TOKEN_ID_1, TOKEN_ID_2], + [MINT_AMOUNT - 1, MINT_AMOUNT + 1], // Different amounts + [price1, price2], deadline, - 0, // Initial nonce + signatureId, // Same signatureId ); - // Mint with signature - await sandboxPasses - .connect(user1) - .mint( + // Second mint with same signatureId should fail + await expect( + sandboxPasses.connect(user1).batchMint( user1.address, - TOKEN_ID_1, - MINT_AMOUNT, - price, + [TOKEN_ID_1, TOKEN_ID_2], + [MINT_AMOUNT - 1, MINT_AMOUNT + 1], + [price1, price2], deadline, - signature, - ); - - // Verify nonce was incremented - expect(await sandboxPasses.getNonce(user1.address)).to.equal(1); + signature2, + signatureId, // Same signatureId + ), + ) + .to.be.revertedWithCustomError(sandboxPasses, 'SignatureAlreadyUsed') + .withArgs(signatureId); }); it('should reject signature with incorrect recipient', async function () { @@ -1154,7 +1086,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1169,7 +1101,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // User1 attempts to use user2's signature @@ -1183,6 +1115,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1200,7 +1133,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1215,7 +1148,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint TOKEN_ID_2 instead @@ -1227,6 +1160,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1243,7 +1177,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1258,7 +1192,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint different amount @@ -1270,6 +1204,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1287,7 +1222,7 @@ describe('GamePasses', function () { const price = ethers.parseEther('0.1'); const incorrectPrice = ethers.parseEther('0.05'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1302,7 +1237,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint with different price @@ -1314,6 +1249,7 @@ describe('GamePasses', function () { incorrectPrice, // Different price deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1333,7 +1269,7 @@ describe('GamePasses', function () { const price1 = ethers.parseEther('0.1'); const price2 = ethers.parseEther('0.2'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1348,7 +1284,7 @@ describe('GamePasses', function () { [MINT_AMOUNT, MINT_AMOUNT * 2], [price1, price2], deadline, - nonce, + signatureId, ); // INVALID TOKEN_ID of the second token @@ -1362,6 +1298,7 @@ describe('GamePasses', function () { [price1, price2], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); @@ -1376,6 +1313,7 @@ describe('GamePasses', function () { [price1, price2], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); @@ -1391,6 +1329,7 @@ describe('GamePasses', function () { [incorrectPrice, price2], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); @@ -1406,6 +1345,7 @@ describe('GamePasses', function () { [price1, price2], incorrectDeadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1428,7 +1368,7 @@ describe('GamePasses', function () { const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1443,7 +1383,7 @@ describe('GamePasses', function () { [3, 3], [price, price], deadline, - nonce, + signatureId, ); // Try to batch mint the same token ID twice (6 + 5 = 11) @@ -1458,6 +1398,7 @@ describe('GamePasses', function () { [price, price], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); }); @@ -1490,7 +1431,7 @@ describe('GamePasses', function () { // First mint const mintAmount1 = 10; - const nonce1 = 0; + const signatureId1 = 12345; // Approve payment token for first mint await paymentToken @@ -1504,7 +1445,7 @@ describe('GamePasses', function () { mintAmount1, price, deadline, - nonce1, + signatureId1, ); // First mint should succeed @@ -1517,6 +1458,7 @@ describe('GamePasses', function () { price, deadline, signature1, + signatureId1, ); // Check balance after first mint @@ -1526,7 +1468,7 @@ describe('GamePasses', function () { // Second mint with a larger amount const mintAmount2 = 20; - const nonce2 = 1; + const signatureId2 = 123456; // Approve payment token for second mint await paymentToken @@ -1540,7 +1482,7 @@ describe('GamePasses', function () { mintAmount2, price, deadline, - nonce2, + signatureId2, ); // Second mint should also succeed despite exceeding what would normally be max per wallet @@ -1553,6 +1495,7 @@ describe('GamePasses', function () { price, deadline, signature2, + signatureId2, ); // Check total balance after both mints @@ -1764,7 +1707,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature const signature = await createBurnAndMintSignature( @@ -1775,7 +1718,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Burn and mint with signature @@ -1789,6 +1732,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ); expect(await sandboxPasses.balanceOf(user1.address, TOKEN_ID_1)).to.equal( @@ -1885,7 +1829,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) - 3600; // 1 hour in the past - const nonce = 0; + const signatureId = 12345; // Create expired signature const signature = await createBurnAndMintSignature( @@ -1896,7 +1840,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn and mint with expired signature @@ -1911,6 +1855,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'SignatureExpired'); }); @@ -1933,7 +1878,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature with unauthorized signer const signature = await createBurnAndMintSignature( @@ -1944,7 +1889,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn and mint @@ -1959,6 +1904,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1981,7 +1927,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature for TOKEN_ID_1 const signature = await createBurnAndMintSignature( @@ -1992,7 +1938,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn TOKEN_ID_2 instead (which user doesn't have) @@ -2005,6 +1951,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2027,7 +1974,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature to burn 2 tokens const signature = await createBurnAndMintSignature( @@ -2038,7 +1985,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn 3 tokens instead @@ -2051,6 +1998,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2073,7 +2021,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature to mint TOKEN_ID_2 const signature = await createBurnAndMintSignature( @@ -2084,7 +2032,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to mint TOKEN_ID_1 instead @@ -2097,6 +2045,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2119,7 +2068,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature to mint 3 tokens const signature = await createBurnAndMintSignature( @@ -2130,7 +2079,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to mint 4 tokens instead @@ -2143,6 +2092,7 @@ describe('GamePasses', function () { 4, // Different mint amount deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2165,7 +2115,7 @@ describe('GamePasses', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT * 2); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature const signature = await createBurnAndMintSignature( @@ -2176,7 +2126,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // First burn and mint should succeed @@ -2190,6 +2140,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ); // Second attempt with same signature should fail (replay attack) @@ -2204,11 +2155,12 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), - ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); + ).to.be.revertedWithCustomError(sandboxPasses, 'SignatureAlreadyUsed'); }); - it('should increment nonce after successful burnAndMint', async function () { + it('should mark signature as used after successful burnAndMint', async function () { const { sandboxPasses, signer, @@ -2225,11 +2177,8 @@ describe('GamePasses', function () { .connect(admin) .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); - // Check initial nonce - expect(await sandboxPasses.getNonce(user1.address)).to.equal(0); - const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature const signature = await createBurnAndMintSignature( @@ -2240,7 +2189,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Burn and mint @@ -2254,10 +2203,13 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ); - // Verify nonce was incremented - expect(await sandboxPasses.getNonce(user1.address)).to.equal(1); + // Verify signature was used + expect(await sandboxPasses.getSignatureStatus(signatureId)).to.equal( + true, + ); }); }); @@ -2533,7 +2485,7 @@ describe('GamePasses', function () { const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature const signature = await createMintSignature( @@ -2543,7 +2495,7 @@ describe('GamePasses', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint while paused @@ -2557,6 +2509,7 @@ describe('GamePasses', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'EnforcedPause'); }); @@ -2863,7 +2816,7 @@ describe('GamePasses', function () { } = await loadFixture(runCreateTestSetup); const NON_CONFIGURED_TOKEN = 999; const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Create signature const signature = await createBurnAndMintSignature( @@ -2874,7 +2827,7 @@ describe('GamePasses', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); await expect( @@ -2888,6 +2841,7 @@ describe('GamePasses', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'BurnMintNotConfigured'); }); diff --git a/packages/game-passes/test/fixtures/game-passes-fixture.ts b/packages/game-passes/test/fixtures/game-passes-fixture.ts index 50e3134506..c6488a4e5b 100644 --- a/packages/game-passes/test/fixtures/game-passes-fixture.ts +++ b/packages/game-passes/test/fixtures/game-passes-fixture.ts @@ -37,7 +37,7 @@ export async function runCreateTestSetup() { amount: number, price: bigint, deadline: number, - nonce: number, + signatureId: number, ): Promise { const chainId = (await ethers.provider.getNetwork()).chainId; @@ -57,7 +57,7 @@ export async function runCreateTestSetup() { {name: 'amount', type: 'uint256'}, {name: 'price', type: 'uint256'}, {name: 'deadline', type: 'uint256'}, - {name: 'nonce', type: 'uint256'}, + {name: 'signatureId', type: 'uint256'}, ], }; @@ -68,7 +68,7 @@ export async function runCreateTestSetup() { amount: amount, price: price, deadline: deadline, - nonce: nonce, + signatureId: signatureId, }; // Sign the typed data properly @@ -84,7 +84,7 @@ export async function runCreateTestSetup() { mintId: number, mintAmount: number, deadline: number, - nonce: number, + signatureId: number, ): Promise { const chainId = (await ethers.provider.getNetwork()).chainId; @@ -105,7 +105,7 @@ export async function runCreateTestSetup() { {name: 'mintId', type: 'uint256'}, {name: 'mintAmount', type: 'uint256'}, {name: 'deadline', type: 'uint256'}, - {name: 'nonce', type: 'uint256'}, + {name: 'signatureId', type: 'uint256'}, ], }; @@ -117,7 +117,7 @@ export async function runCreateTestSetup() { mintId: mintId, mintAmount: mintAmount, deadline: deadline, - nonce: nonce, + signatureId: signatureId, }; // Sign the typed data properly @@ -132,7 +132,7 @@ export async function runCreateTestSetup() { amounts: number[], prices: bigint[], deadline: number, - nonce: number, + signatureId: number, ): Promise { const chainId = (await ethers.provider.getNetwork()).chainId; @@ -152,7 +152,7 @@ export async function runCreateTestSetup() { {name: 'amounts', type: 'uint256[]'}, {name: 'prices', type: 'uint256[]'}, {name: 'deadline', type: 'uint256'}, - {name: 'nonce', type: 'uint256'}, + {name: 'signatureId', type: 'uint256'}, ], }; @@ -163,7 +163,7 @@ export async function runCreateTestSetup() { amounts: amounts, prices: prices, deadline: deadline, - nonce: nonce, + signatureId: signatureId, }; // Sign the typed data properly @@ -249,6 +249,7 @@ export async function runCreateTestSetup() { BigNumberish, BigNumberish, BytesLike, + BigNumberish, ], ) => { const encodedData = sandboxPasses.interface.encodeFunctionData( @@ -273,6 +274,7 @@ export async function runCreateTestSetup() { BigNumberish[], BigNumberish, BytesLike, + BigNumberish, ], ) => { const encodedData = sandboxPasses.interface.encodeFunctionData( From 164d80a6b6ee5c02b36ad99c1c81250a96f4c212 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Tue, 25 Mar 2025 19:50:27 +0100 Subject: [PATCH 15/28] Use max uint for unlimited editions --- packages/game-passes/contracts/GamePasses.sol | 39 ++-- packages/game-passes/test/GamePasses.test.ts | 184 +++++++++++++++++- 2 files changed, 203 insertions(+), 20 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 8e799d04de..db4100d13e 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -751,8 +751,8 @@ contract GamePasses is * @notice Configure a new token with its properties and restrictions * @param tokenId The token ID to configure * @param transferable Whether the token can be transferred between users - * @param maxSupply Maximum supply (0 for unlimited/open edition) - * @param maxPerWallet Maximum tokens that can be minted per wallet (0 for unlimited) + * @param maxSupply Maximum supply (0 for disabled, type(uint256).max for unlimited/open edition) + * @param maxPerWallet Maximum tokens that can be minted per wallet (0 for disabled, type(uint256).max for unlimited) * @param metadata Token metadata string (typically IPFS hash or other identifier) * @param treasuryWallet Specific treasury wallet for this token (or address(0) for default) * @dev Only callable by addresses with ADMIN_ROLE @@ -789,8 +789,8 @@ contract GamePasses is /** * @notice Update existing token configuration * @param tokenId The token ID to update - * @param maxSupply New maximum supply (0 for open edition) - * @param maxPerWallet New maximum tokens per wallet (0 for unlimited) + * @param maxSupply New maximum supply (0 for disabled, type(uint256).max for unlimited/open edition) + * @param maxPerWallet New maximum tokens per wallet (0 for disabled, type(uint256).max for unlimited) * @param metadata New metadata string (typically IPFS hash) * @param treasuryWallet New treasury wallet (or address(0) for default) * @dev Only callable by addresses with ADMIN_ROLE @@ -815,11 +815,9 @@ contract GamePasses is } // Cannot decrease maxSupply below current supply - if (maxSupply > 0) { - uint256 currentSupply = totalSupply(tokenId); - if (maxSupply < currentSupply) { - revert MaxSupplyBelowCurrentSupply(tokenId); - } + uint256 currentSupply = totalSupply(tokenId); + if (maxSupply < currentSupply) { + revert MaxSupplyBelowCurrentSupply(tokenId); } config.maxSupply = maxSupply; @@ -1344,17 +1342,17 @@ contract GamePasses is * @param amount The amount to mint * @dev Used internally before any mint operation * @dev Reverts if: - * - Token has a max supply (> 0) and + * - Token has maxSupply = 0 (minting disabled) or * - Current supply + amount would exceed max supply */ function _checkMaxSupply(uint256 tokenId, uint256 amount) private { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; // update the config total minted and check if it exceeds the max supply config.totalMinted += amount; - if (config.maxSupply > 0) { - if (config.totalMinted > config.maxSupply) { - revert MaxSupplyExceeded(tokenId); - } + + // Otherwise check if it exceeds the max supply (unlimited if maxSupply is type(uint256).max) + if (config.maxSupply != type(uint256).max && config.totalMinted > config.maxSupply) { + revert MaxSupplyExceeded(tokenId); } } @@ -1365,13 +1363,20 @@ contract GamePasses is * @param amount The amount to mint * @dev Used internally before user mint operations * @dev Reverts if: + * - maxPerWallet is 0 (per-wallet minting disabled) or * - Current wallet balance + amount would exceed max per wallet - * @dev Skips check if maxPerWallet is 0 (unlimited) + * @dev Skips check if maxPerWallet is type(uint256).max (unlimited) */ function _checkMaxPerWallet(uint256 tokenId, address to, uint256 amount) private view { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; - // Skip check if maxPerWallet is 0 (unlimited) - if (config.maxPerWallet > 0 && config.mintedPerWallet[to] + amount > config.maxPerWallet) { + + // If maxPerWallet is 0, per-wallet minting is disabled + if (config.maxPerWallet == 0) { + revert ExceedsMaxPerWallet(tokenId, to, amount, 0); + } + + // Skip check if maxPerWallet is type(uint256).max (unlimited) + if (config.maxPerWallet != type(uint256).max && config.mintedPerWallet[to] + amount > config.maxPerWallet) { revert ExceedsMaxPerWallet(tokenId, to, amount, config.maxPerWallet); } } diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 3a62054e67..71b7c5f8c2 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -1403,7 +1403,7 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); }); - it('should allow unlimited mints when maxPerWallet is 0', async function () { + it('should allow unlimited mints when maxPerWallet is type(uint256).max', async function () { const { sandboxPasses, signer, @@ -1413,7 +1413,7 @@ describe('GamePasses', function () { createMintSignature, } = await loadFixture(runCreateTestSetup); - // Configure a new token with maxPerWallet = 0 (unlimited) + // Configure a new token with maxPerWallet = type(uint256).max (unlimited) const UNLIMITED_TOKEN_ID = 9999; const LARGE_MAX_SUPPLY = 1000; // Just to make sure we don't hit max supply @@ -1421,7 +1421,7 @@ describe('GamePasses', function () { UNLIMITED_TOKEN_ID, true, // transferable LARGE_MAX_SUPPLY, // max supply - 0, // maxPerWallet = 0 (unlimited) + ethers.MaxUint256, // maxPerWallet = type(uint256).max (unlimited) 'ipfs://unlimited-token', // metadata ethers.ZeroAddress, // use default treasury ); @@ -1510,6 +1510,184 @@ describe('GamePasses', function () { ); expect(mintedPerWallet).to.equal(mintAmount1 + mintAmount2); }); + + it('should reject mints when maxPerWallet is 0 (disabled)', async function () { + const { + sandboxPasses, + signer, + user1, + paymentToken, + admin, + createMintSignature, + } = await loadFixture(runCreateTestSetup); + + // Configure a new token with maxPerWallet = 0 (disabled) + const DISABLED_TOKEN_ID = 8888; + const LARGE_MAX_SUPPLY = 1000; + + await sandboxPasses.connect(admin).configureToken( + DISABLED_TOKEN_ID, + true, // transferable + LARGE_MAX_SUPPLY, // max supply + 0, // maxPerWallet = 0 (disabled) + 'ipfs://disabled-token', // metadata + ethers.ZeroAddress, // use default treasury + ); + + const price = ethers.parseEther('0.1'); + const deadline = (await time.latest()) + 3600; // 1 hour from now + const mintAmount = 10; + const signatureId = 12345; + + // Approve payment token + await paymentToken + .connect(user1) + .approve(await sandboxPasses.getAddress(), price); + + const signature = await createMintSignature( + signer, + user1.address, + DISABLED_TOKEN_ID, + mintAmount, + price, + deadline, + signatureId, + ); + + // Mint should be rejected because maxPerWallet is 0 (disabled) + await expect( + sandboxPasses + .connect(user1) + .mint( + user1.address, + DISABLED_TOKEN_ID, + mintAmount, + price, + deadline, + signature, + signatureId, + ), + ).to.be.revertedWithCustomError(sandboxPasses, 'ExceedsMaxPerWallet'); + }); + + it('should reject mints when maxSupply is 0 (disabled)', async function () { + const { + sandboxPasses, + signer, + user1, + paymentToken, + admin, + createMintSignature, + } = await loadFixture(runCreateTestSetup); + + // Configure a new token with maxSupply = 0 (disabled) + const DISABLED_TOKEN_ID = 7777; + + await sandboxPasses.connect(admin).configureToken( + DISABLED_TOKEN_ID, + true, // transferable + 0, // maxSupply = 0 (disabled) + 10, // maxPerWallet = 10 + 'ipfs://disabled-supply-token', // metadata + ethers.ZeroAddress, // use default treasury + ); + + const price = ethers.parseEther('0.1'); + const deadline = (await time.latest()) + 3600; // 1 hour from now + const mintAmount = 5; + const signatureId = 12345; + + // Approve payment token + await paymentToken + .connect(user1) + .approve(await sandboxPasses.getAddress(), price); + + const signature = await createMintSignature( + signer, + user1.address, + DISABLED_TOKEN_ID, + mintAmount, + price, + deadline, + signatureId, + ); + + // Mint should be rejected because maxSupply is 0 (disabled) + await expect( + sandboxPasses + .connect(user1) + .mint( + user1.address, + DISABLED_TOKEN_ID, + mintAmount, + price, + deadline, + signature, + signatureId, + ), + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + }); + + it('should allow unlimited supply when maxSupply is type(uint256).max', async function () { + const { + sandboxPasses, + signer, + user1, + paymentToken, + admin, + createMintSignature, + } = await loadFixture(runCreateTestSetup); + + // Configure a new token with maxSupply = type(uint256).max (unlimited) + const UNLIMITED_TOKEN_ID = 6666; + const NORMAL_MAX_PER_WALLET = 50; + + await sandboxPasses.connect(admin).configureToken( + UNLIMITED_TOKEN_ID, + true, // transferable + ethers.MaxUint256, // maxSupply = type(uint256).max (unlimited) + NORMAL_MAX_PER_WALLET, // maxPerWallet + 'ipfs://unlimited-supply-token', // metadata + ethers.ZeroAddress, // use default treasury + ); + + const price = ethers.parseEther('0.1'); + const deadline = (await time.latest()) + 3600; // 1 hour from now + const mintAmount = 25; // within per-wallet limit + const signatureId = 12345; + + // Approve payment token + await paymentToken + .connect(user1) + .approve(await sandboxPasses.getAddress(), price); + + const signature = await createMintSignature( + signer, + user1.address, + UNLIMITED_TOKEN_ID, + mintAmount, + price, + deadline, + signatureId, + ); + + // Mint should succeed with unlimited supply + await sandboxPasses + .connect(user1) + .mint( + user1.address, + UNLIMITED_TOKEN_ID, + mintAmount, + price, + deadline, + signature, + signatureId, + ); + + expect( + await sandboxPasses.balanceOf(user1.address, UNLIMITED_TOKEN_ID), + ).to.equal(mintAmount); + }); }); describe('Burn and Mint Operations', function () { From 5645525218b516630430e314874f7f03ccebab1b Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Tue, 25 Mar 2025 19:58:44 +0100 Subject: [PATCH 16/28] Removed unnecessary comments --- packages/game-passes/contracts/GamePasses.sol | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index db4100d13e..7cbefe5155 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -312,23 +312,19 @@ contract GamePasses is __ERC2981_init(); __Pausable_init(); - // Validate inputs if (_admin == address(0)) revert ZeroAddress("admin"); if (_defaultTreasury == address(0)) revert ZeroAddress("treasury"); if (_paymentToken == address(0)) revert ZeroAddress("payment token"); - // Check if _paymentToken is a contract if (_paymentToken.code.length == 0) { revert InvalidPaymentToken(); } - // Set up AccessControl roles _grantRole(DEFAULT_ADMIN_ROLE, _admin); _grantRole(ADMIN_ROLE, _admin); _grantRole(OPERATOR_ROLE, _operator); _grantRole(SIGNER_ROLE, _signer); - // Initialize storage structs CoreStorage storage cs = _coreStorage(); cs.paymentToken = _paymentToken; cs.baseURI = _baseURI; @@ -562,10 +558,9 @@ contract GamePasses is _checkMaxSupply(mintTokenId, mintAmount); mintConfig.mintedPerWallet[mintTo] += mintAmount; - // Burn first + _burn(burnFrom, burnTokenId, burnAmount); - // Then mint _checkMaxPerWallet(mintTokenId, mintTo, mintAmount); _mint(mintTo, mintTokenId, mintAmount, ""); } @@ -619,10 +614,8 @@ contract GamePasses is mintConfig.mintedPerWallet[mintTo] += mintAmounts[i]; } - // Burn tokens first _burnBatch(burnFrom, burnTokenIds, burnAmounts); - // Then mint new tokens _mintBatch(mintTo, mintTokenIds, mintAmounts, ""); } @@ -661,7 +654,6 @@ contract GamePasses is revert BurnMintNotConfigured(burnId); } - // Check if mint token is configured and respects max supply TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintId]; if (!mintConfig.isConfigured) { revert TokenNotConfigured(mintId); @@ -681,10 +673,9 @@ contract GamePasses is _checkMaxSupply(mintId, mintAmount); mintConfig.mintedPerWallet[caller] += mintAmount; - // Burn first + _burn(caller, burnId, burnAmount); - // Then mint new token _checkMaxPerWallet(mintId, caller, mintAmount); _mint(caller, mintId, mintAmount, ""); } @@ -814,7 +805,6 @@ contract GamePasses is revert TokenNotConfigured(tokenId); } - // Cannot decrease maxSupply below current supply uint256 currentSupply = totalSupply(tokenId); if (maxSupply < currentSupply) { revert MaxSupplyBelowCurrentSupply(tokenId); @@ -1031,7 +1021,6 @@ contract GamePasses is * @dev Cannot recover the payment token if contract is not paused */ function recoverERC20(address token, address to, uint256 amount) external onlyRole(ADMIN_ROLE) { - // If attempting to recover the payment token, contract must be paused if (token == _coreStorage().paymentToken && !paused()) { revert PaymentTokenRecoveryNotAllowed(); } @@ -1246,7 +1235,6 @@ contract GamePasses is _checkMaxPerWallet(request.tokenId, request.caller, request.amount); _checkMaxSupply(request.tokenId, request.amount); - // Update minted amount for wallet config.mintedPerWallet[request.caller] += request.amount; address treasury = config.treasuryWallet; @@ -1273,7 +1261,6 @@ contract GamePasses is verifyBatchSignature(request, signature); - // Process each mint separately for (uint256 i; i < request.tokenIds.length; i++) { TokenConfig storage config = _tokenStorage().tokenConfigs[request.tokenIds[i]]; @@ -1284,7 +1271,6 @@ contract GamePasses is _checkMaxPerWallet(request.tokenIds[i], request.caller, request.amounts[i]); _checkMaxSupply(request.tokenIds[i], request.amounts[i]); - // Update minted amount for wallet config.mintedPerWallet[request.caller] += request.amounts[i]; address treasury = config.treasuryWallet; @@ -1299,7 +1285,6 @@ contract GamePasses is ); } - // Perform batch mint _mintBatch(request.caller, request.tokenIds, request.amounts, ""); } @@ -1347,10 +1332,8 @@ contract GamePasses is */ function _checkMaxSupply(uint256 tokenId, uint256 amount) private { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; - // update the config total minted and check if it exceeds the max supply config.totalMinted += amount; - // Otherwise check if it exceeds the max supply (unlimited if maxSupply is type(uint256).max) if (config.maxSupply != type(uint256).max && config.totalMinted > config.maxSupply) { revert MaxSupplyExceeded(tokenId); } @@ -1370,12 +1353,10 @@ contract GamePasses is function _checkMaxPerWallet(uint256 tokenId, address to, uint256 amount) private view { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; - // If maxPerWallet is 0, per-wallet minting is disabled if (config.maxPerWallet == 0) { revert ExceedsMaxPerWallet(tokenId, to, amount, 0); } - // Skip check if maxPerWallet is type(uint256).max (unlimited) if (config.maxPerWallet != type(uint256).max && config.mintedPerWallet[to] + amount > config.maxPerWallet) { revert ExceedsMaxPerWallet(tokenId, to, amount, config.maxPerWallet); } From 0cf0231de979bcf7d3e7f342c02f0d0aa9cf9da1 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Wed, 26 Mar 2025 20:36:11 +0100 Subject: [PATCH 17/28] Do a check for the caller for user functions --- packages/game-passes/contracts/GamePasses.sol | 103 +++++++++++------- packages/game-passes/test/GamePasses.test.ts | 66 ++++++----- .../test/fixtures/game-passes-fixture.ts | 22 ++-- 3 files changed, 111 insertions(+), 80 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 7cbefe5155..4f9647944b 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -77,6 +77,20 @@ contract GamePasses is uint256 signatureId; } + /// @dev Struct to hold initialization parameters + struct InitParams { + string baseURI; + address royaltyReceiver; + uint96 royaltyFeeNumerator; + address admin; + address operator; + address signer; + address paymentToken; + address trustedForwarder; + address defaultTreasury; + address owner; + } + // ============================================================= // Storage - ERC7201 // ============================================================= @@ -87,7 +101,7 @@ contract GamePasses is string baseURI; // Default treasury wallet address defaultTreasuryWallet; - // Payment token + // Payment token, SAND contract address paymentToken; // Owner of the contract address internalOwner; @@ -270,6 +284,8 @@ contract GamePasses is error TransferWhitelistAlreadySet(uint256 tokenId, address account); /// @dev Revert when transferability is already set error TransferabilityAlreadySet(uint256 tokenId); + /// @dev Revert when sender of the transaction does not equal the caller value + error InvalidSender(); // ============================================================= // Init @@ -282,54 +298,45 @@ contract GamePasses is /** * @notice Initializes the upgradeable contract (replaces constructor). - * @param _baseURI Initial base URI for metadata. - * @param _royaltyReceiver Address to receive royalty fees. - * @param _royaltyFeeNumerator Royalty fee in basis points (e.g. 500 => 5%). - * @param _admin Address that will be granted the ADMIN_ROLE. - * @param _operator Address that will be granted the OPERATOR_ROLE. - * @param _signer Address that will be granted the SIGNER_ROLE. - * @param _paymentToken Address of the ERC20 token used for payments. - * @param _trustedForwarder Address of the trusted meta-transaction forwarder. - * @param _defaultTreasury Address of the default treasury wallet. - * @param _owner Address that will be set as the internal owner. + * @param params Struct containing all initialization parameters: + * - baseURI: Initial base URI for metadata. + * - royaltyReceiver: Address to receive royalty fees. + * - royaltyFeeNumerator: Royalty fee in basis points (e.g. 500 => 5%). + * - admin: Address that will be granted the ADMIN_ROLE. + * - operator: Address that will be granted the OPERATOR_ROLE. + * - signer: Address that will be granted the SIGNER_ROLE. + * - paymentToken: Address of the ERC20 token used for payments. + * - trustedForwarder: Address of the trusted meta-transaction forwarder. + * - defaultTreasury: Address of the default treasury wallet. + * - owner: Address that will be set as the internal owner. + * - sandContract: Address of the SAND contract. */ - function initialize( - string memory _baseURI, - address _royaltyReceiver, - uint96 _royaltyFeeNumerator, - address _admin, - address _operator, - address _signer, - address _paymentToken, - address _trustedForwarder, - address _defaultTreasury, - address _owner - ) external initializer { - __ERC2771Handler_init(_trustedForwarder); + function initialize(InitParams calldata params) external initializer { + __ERC2771Handler_init(params.trustedForwarder); __AccessControl_init(); - __ERC1155_init(_baseURI); + __ERC1155_init(params.baseURI); __ERC1155Supply_init(); __ERC2981_init(); __Pausable_init(); - if (_admin == address(0)) revert ZeroAddress("admin"); - if (_defaultTreasury == address(0)) revert ZeroAddress("treasury"); - if (_paymentToken == address(0)) revert ZeroAddress("payment token"); + if (params.admin == address(0)) revert ZeroAddress("admin"); + if (params.defaultTreasury == address(0)) revert ZeroAddress("treasury"); + if (params.paymentToken == address(0)) revert ZeroAddress("payment token"); - if (_paymentToken.code.length == 0) { + if (params.paymentToken.code.length == 0) { revert InvalidPaymentToken(); } - _grantRole(DEFAULT_ADMIN_ROLE, _admin); - _grantRole(ADMIN_ROLE, _admin); - _grantRole(OPERATOR_ROLE, _operator); - _grantRole(SIGNER_ROLE, _signer); + _grantRole(DEFAULT_ADMIN_ROLE, params.admin); + _grantRole(ADMIN_ROLE, params.admin); + _grantRole(OPERATOR_ROLE, params.operator); + _grantRole(SIGNER_ROLE, params.signer); CoreStorage storage cs = _coreStorage(); - cs.paymentToken = _paymentToken; - cs.baseURI = _baseURI; - cs.defaultTreasuryWallet = _defaultTreasury; - cs.internalOwner = _owner; + cs.paymentToken = params.paymentToken; + cs.baseURI = params.baseURI; + cs.defaultTreasuryWallet = params.defaultTreasury; + cs.internalOwner = params.owner; cs.DOMAIN_SEPARATOR = keccak256( abi.encode( EIP712_DOMAIN_TYPEHASH, @@ -340,7 +347,7 @@ contract GamePasses is ) ); - _setDefaultRoyalty(_royaltyReceiver, _royaltyFeeNumerator); + _setDefaultRoyalty(params.royaltyReceiver, params.royaltyFeeNumerator); } // ============================================================= @@ -359,6 +366,7 @@ contract GamePasses is * @dev Updates the per-wallet minting count and transfers payment to the appropriate treasury * @dev Reverts if: * - Contract is paused + * - Caller is not the same as msg.sender and its not an approveAndCall operation through SAND contract * - Token is not configured * - Signature is invalid or expired * - Max supply would be exceeded @@ -374,6 +382,11 @@ contract GamePasses is bytes calldata signature, uint256 signatureId ) external whenNotPaused { + CoreStorage storage cs = _coreStorage(); + if (_msgSender() != caller && _msgSender() != cs.paymentToken) { + revert InvalidSender(); + } + MintRequest memory request = MintRequest({ caller: caller, tokenId: tokenId, @@ -387,7 +400,7 @@ contract GamePasses is /** * @notice Batch mints multiple tokens with a single valid EIP-712 signature in a transaction - * @param caller Address that will receive the tokens (must be same as msg.sender) + * @param caller Address that will receive the tokens * @param tokenIds Array of token IDs to mint * @param amounts Array of amounts to mint for each token ID * @param prices Array of prices to pay for each mint operation @@ -398,6 +411,7 @@ contract GamePasses is * @dev Updates per-wallet minting counts and transfers payments to appropriate treasuries * @dev Reverts if: * - Contract is paused + * - Caller is not the same as msg.sender and its not an approveAndCall operation through SAND contract * - Array lengths don't match * - Batch size exceeds MAX_BATCH_SIZE * - Any token is not configured @@ -415,6 +429,10 @@ contract GamePasses is bytes calldata signature, uint256 signatureId ) external whenNotPaused { + CoreStorage storage cs = _coreStorage(); + if (_msgSender() != caller && _msgSender() != cs.paymentToken) { + revert InvalidSender(); + } BatchMintRequest memory request = BatchMintRequest({ caller: caller, tokenIds: tokenIds, @@ -632,7 +650,7 @@ contract GamePasses is * @dev Contract must not be paused * @dev Reverts if: * - Contract is paused - * - from address doesn't match msg.sender + * - Caller is not the same as msg.sender and its not an approveAndCall operation through SAND contract * - Burn token is not configured * - Mint token is not configured * - Signature is invalid or expired @@ -649,6 +667,11 @@ contract GamePasses is bytes calldata signature, uint256 signatureId ) external whenNotPaused { + CoreStorage storage cs = _coreStorage(); + if (_msgSender() != caller && _msgSender() != cs.paymentToken) { + revert InvalidSender(); + } + TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnId]; if (!burnConfig.isConfigured) { revert BurnMintNotConfigured(burnId); diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 71b7c5f8c2..df98009d08 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -3056,16 +3056,18 @@ describe('GamePasses', function () { // Try with zero admin address await expect( upgrades.deployProxy(SandboxPasses, [ - BASE_URI, - royaltyReceiver.address, - ROYALTY_PERCENTAGE, - ethers.ZeroAddress, // Zero admin address - operator.address, - signer.address, - await paymentToken.getAddress(), - trustedForwarder.address, - treasury.address, - admin.address, + { + baseURI: BASE_URI, + royaltyReceiver: royaltyReceiver.address, + royaltyFeeNumerator: ROYALTY_PERCENTAGE, + admin: ethers.ZeroAddress, // Zero admin address + operator: operator.address, + signer: signer.address, + paymentToken: await paymentToken.getAddress(), + trustedForwarder: trustedForwarder.address, + defaultTreasury: treasury.address, + owner: admin.address, + }, ]), ).to.be.revertedWithCustomError( await SandboxPasses.deploy(), @@ -3075,16 +3077,18 @@ describe('GamePasses', function () { // Try with zero treasury address await expect( upgrades.deployProxy(SandboxPasses, [ - BASE_URI, - royaltyReceiver.address, - ROYALTY_PERCENTAGE, - admin.address, - operator.address, - signer.address, - await paymentToken.getAddress(), - trustedForwarder.address, - ethers.ZeroAddress, // Zero treasury address - admin.address, + { + baseURI: BASE_URI, + royaltyReceiver: royaltyReceiver.address, + royaltyFeeNumerator: ROYALTY_PERCENTAGE, + admin: admin.address, + operator: operator.address, + signer: signer.address, + paymentToken: await paymentToken.getAddress(), + trustedForwarder: trustedForwarder.address, + defaultTreasury: ethers.ZeroAddress, // Zero treasury address + owner: admin.address, + }, ]), ).to.be.revertedWithCustomError(sandboxPasses, 'ZeroAddress'); }); @@ -3106,16 +3110,18 @@ describe('GamePasses', function () { // Deploy with an EOA as payment token (which is not a valid ERC20) await expect( upgrades.deployProxy(SandboxPasses, [ - BASE_URI, - royaltyReceiver.address, - ROYALTY_PERCENTAGE, - admin.address, - operator.address, - signer.address, - user1.address, // Not an ERC20 token - trustedForwarder.address, - treasury.address, - admin.address, + { + baseURI: BASE_URI, + royaltyReceiver: royaltyReceiver.address, + royaltyFeeNumerator: ROYALTY_PERCENTAGE, + admin: admin.address, + operator: operator.address, + signer: signer.address, + paymentToken: user1.address, // Not an ERC20 token + trustedForwarder: trustedForwarder.address, + defaultTreasury: treasury.address, + owner: admin.address, + }, ]), ).to.be.revertedWithCustomError(SandboxPasses, 'InvalidPaymentToken'); }); diff --git a/packages/game-passes/test/fixtures/game-passes-fixture.ts b/packages/game-passes/test/fixtures/game-passes-fixture.ts index c6488a4e5b..044a51aba1 100644 --- a/packages/game-passes/test/fixtures/game-passes-fixture.ts +++ b/packages/game-passes/test/fixtures/game-passes-fixture.ts @@ -197,16 +197,18 @@ export async function runCreateTestSetup() { // Deploy the contract using upgrades plugin const SandboxPasses = await ethers.getContractFactory('GamePasses'); const sandboxPasses = (await upgrades.deployProxy(SandboxPasses, [ - BASE_URI, - royaltyReceiver.address, - ROYALTY_PERCENTAGE, - admin.address, - operator.address, - signer.address, - await paymentToken.getAddress(), - trustedForwarder.address, - treasury.address, - owner.address, + { + baseURI: BASE_URI, + royaltyReceiver: royaltyReceiver.address, + royaltyFeeNumerator: ROYALTY_PERCENTAGE, + admin: admin.address, + operator: operator.address, + signer: signer.address, + paymentToken: await paymentToken.getAddress(), + trustedForwarder: trustedForwarder.address, + defaultTreasury: treasury.address, + owner: owner.address, + }, ])) as unknown as GamePasses; await sandboxPasses.waitForDeployment(); From 5a61f9da02192e89a38ce629027b63471134a8d1 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Wed, 26 Mar 2025 21:10:15 +0100 Subject: [PATCH 18/28] Disallow same id for burn and mint and setting passes contract as treasury --- packages/game-passes/contracts/GamePasses.sol | 31 ++- packages/game-passes/test/GamePasses.test.ts | 208 +++++++++++++++++- 2 files changed, 232 insertions(+), 7 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 4f9647944b..f3bc718a99 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -250,8 +250,6 @@ contract GamePasses is error TokenNotConfigured(uint256 tokenId); /// @dev Revert when trying to mint more tokens than the max supply error MaxSupplyExceeded(uint256 tokenId); - /// @dev Revert when burn and mint configuration doesn't exist - error BurnMintNotConfigured(uint256 burnTokenId); /// @dev Revert when token is already configured error TokenAlreadyConfigured(uint256 tokenId); /// @dev Revert when array lengths mismatch @@ -286,6 +284,10 @@ contract GamePasses is error TransferabilityAlreadySet(uint256 tokenId); /// @dev Revert when sender of the transaction does not equal the caller value error InvalidSender(); + /// @dev Revert when burn and mint token IDs are the same + error SameTokenIdBurnAndMint(uint256 tokenId); + /// @dev Revert when treasury wallet is the same as the contract address + error InvalidTreasuryWallet(); // ============================================================= // Init @@ -568,6 +570,10 @@ contract GamePasses is uint256 mintTokenId, uint256 mintAmount ) external whenNotPaused onlyRole(OPERATOR_ROLE) { + if (burnTokenId == mintTokenId) { + revert SameTokenIdBurnAndMint(burnTokenId); + } + TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintTokenId]; if (!mintConfig.isConfigured) { @@ -619,6 +625,13 @@ contract GamePasses is revert ArrayLengthMismatch(); } + // Check for same token IDs in burn and mint operations + for (uint256 i; i < burnTokenIds.length; i++) { + if (burnTokenIds[i] == mintTokenIds[i]) { + revert SameTokenIdBurnAndMint(burnTokenIds[i]); + } + } + // Validate mint tokens and check max supply for (uint256 i; i < mintTokenIds.length; i++) { TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintTokenIds[i]]; @@ -672,9 +685,13 @@ contract GamePasses is revert InvalidSender(); } + if (burnId == mintId) { + revert SameTokenIdBurnAndMint(burnId); + } + TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnId]; if (!burnConfig.isConfigured) { - revert BurnMintNotConfigured(burnId); + revert TokenNotConfigured(burnId); } TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintId]; @@ -790,6 +807,10 @@ contract GamePasses is revert TokenAlreadyConfigured(tokenId); } + if (treasuryWallet == address(this)) { + revert InvalidTreasuryWallet(); + } + config.isConfigured = true; config.transferable = transferable; config.maxSupply = maxSupply; @@ -833,6 +854,10 @@ contract GamePasses is revert MaxSupplyBelowCurrentSupply(tokenId); } + if (treasuryWallet == address(this)) { + revert InvalidTreasuryWallet(); + } + config.maxSupply = maxSupply; config.maxPerWallet = maxPerWallet; config.metadata = metadata; diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index df98009d08..88b70d7ec0 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -287,6 +287,39 @@ describe('GamePasses', function () { ); }); + it('should not allow configuring a token with the same treasury wallet as the contract', async function () { + const {sandboxPasses, admin, TOKEN_ID_3} = + await loadFixture(runCreateTestSetup); + + await expect( + sandboxPasses + .connect(admin) + .configureToken( + TOKEN_ID_3, + true, + 100, + 10, + 'ipfs://QmToken1', + await sandboxPasses.getAddress(), + ), + ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidTreasuryWallet'); + }); + it('should not allow updating treasury wallet to the same address as the contract', async function () { + const {sandboxPasses, admin, TOKEN_ID_1} = + await loadFixture(runCreateTestSetup); + + await expect( + sandboxPasses + .connect(admin) + .updateTokenConfig( + TOKEN_ID_1, + 100, + 10, + 'ipfs://QmToken1', + await sandboxPasses.getAddress(), + ), + ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidTreasuryWallet'); + }); it('should not allow non-admin to update token configuration', async function () { const {sandboxPasses, user1, TOKEN_ID_1} = await loadFixture(runCreateTestSetup); @@ -2095,6 +2128,7 @@ describe('GamePasses', function () { admin, TOKEN_ID_1, TOKEN_ID_2, + TOKEN_ID_3, MINT_AMOUNT, createBurnAndMintSignature, } = await loadFixture(runCreateTestSetup); @@ -2107,6 +2141,16 @@ describe('GamePasses', function () { const deadline = (await time.latest()) + 3600; const signatureId = 12345; + // configure TOKEN_ID_3 + await sandboxPasses.connect(admin).configureToken( + TOKEN_ID_3, + true, // transferable + 100, // max supply + 10, // max per wallet + `ipfs://token${TOKEN_ID_3}`, // metadata + ethers.ZeroAddress, // use default treasury + ); + // Create signature for TOKEN_ID_1 const signature = await createBurnAndMintSignature( signer, @@ -2123,7 +2167,7 @@ describe('GamePasses', function () { await expect( sandboxPasses.connect(user1).burnAndMint( user1.address, - TOKEN_ID_2, // Different token ID to burn + TOKEN_ID_3, // Different token ID to burn 2, TOKEN_ID_2, 3, @@ -2189,6 +2233,7 @@ describe('GamePasses', function () { admin, TOKEN_ID_1, TOKEN_ID_2, + TOKEN_ID_3, MINT_AMOUNT, createBurnAndMintSignature, } = await loadFixture(runCreateTestSetup); @@ -2213,13 +2258,23 @@ describe('GamePasses', function () { signatureId, ); + // configure TOKEN_ID_3 + await sandboxPasses.connect(admin).configureToken( + TOKEN_ID_3, + true, // transferable + 100, // max supply + 10, // max per wallet + `ipfs://token${TOKEN_ID_3}`, // metadata + ethers.ZeroAddress, // use default treasury + ); + // Try to mint TOKEN_ID_1 instead await expect( sandboxPasses.connect(user1).burnAndMint( user1.address, TOKEN_ID_1, 2, - TOKEN_ID_1, // Different mint token ID + TOKEN_ID_3, // Different mint token ID 3, deadline, signature, @@ -2984,7 +3039,7 @@ describe('GamePasses', function () { }); describe('Additional Error Cases', function () { - it('should revert with BurnMintNotConfigured for unconfigured burn token', async function () { + it('should revert with TokenNotConfigured for unconfigured burn token', async function () { const { sandboxPasses, signer, @@ -3021,7 +3076,7 @@ describe('GamePasses', function () { signature, signatureId, ), - ).to.be.revertedWithCustomError(sandboxPasses, 'BurnMintNotConfigured'); + ).to.be.revertedWithCustomError(sandboxPasses, 'TokenNotConfigured'); }); it('should revert with ArrayLengthMismatch in batch operations', async function () { @@ -3126,4 +3181,149 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(SandboxPasses, 'InvalidPaymentToken'); }); }); + + describe('Burn and Mint Validations', function () { + it('should revert when burning and minting the same token ID in burnAndMint', async function () { + const { + sandboxPasses, + admin, + user1, + TOKEN_ID_1, + MINT_AMOUNT, + createBurnAndMintSignature, + } = await loadFixture(runCreateTestSetup); + + // First mint some tokens to the user + await sandboxPasses + .connect(admin) + .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); + + // Create signature for burning and minting the same token + const burnAmount = MINT_AMOUNT; + const mintAmount = MINT_AMOUNT; + const deadline = (await time.latest()) + 3600; + const signatureId = 12345; + + const signature = await createBurnAndMintSignature( + admin, + user1.address, + TOKEN_ID_1, // Same token ID for burn + burnAmount, + TOKEN_ID_1, // Same token ID for mint + mintAmount, + deadline, + signatureId, + ); + + // Should revert with SameTokenIdBurnAndMint + await expect( + sandboxPasses.connect(user1).burnAndMint( + user1.address, + TOKEN_ID_1, + burnAmount, + TOKEN_ID_1, // Same token ID + mintAmount, + deadline, + signature, + signatureId, + ), + ) + .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') + .withArgs(TOKEN_ID_1); + }); + + it('should revert when burning and minting the same token ID in operatorBurnAndMint', async function () { + const {sandboxPasses, admin, operator, user1, TOKEN_ID_1, MINT_AMOUNT} = + await loadFixture(runCreateTestSetup); + + // First mint some tokens to the user + await sandboxPasses + .connect(admin) + .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); + + // Should revert with SameTokenIdBurnAndMint + await expect( + sandboxPasses.connect(operator).operatorBurnAndMint( + user1.address, + user1.address, + TOKEN_ID_1, // Same token ID for burn + MINT_AMOUNT, + TOKEN_ID_1, // Same token ID for mint + MINT_AMOUNT, + ), + ) + .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') + .withArgs(TOKEN_ID_1); + }); + + it('should revert when burning and minting the same token ID in operatorBatchBurnAndMint', async function () { + const { + sandboxPasses, + admin, + operator, + user1, + TOKEN_ID_1, + TOKEN_ID_2, + TOKEN_ID_3, + MINT_AMOUNT, + } = await loadFixture(runCreateTestSetup); + + // First mint some tokens to the user + await sandboxPasses + .connect(admin) + .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); + await sandboxPasses + .connect(admin) + .adminMint(user1.address, TOKEN_ID_2, MINT_AMOUNT); + + // Should revert with SameTokenIdBurnAndMint for the first matching token ID + await expect( + sandboxPasses.connect(operator).operatorBatchBurnAndMint( + user1.address, + user1.address, + [TOKEN_ID_1, TOKEN_ID_2], // Burn token IDs + [MINT_AMOUNT, MINT_AMOUNT], // Burn amounts + [TOKEN_ID_1, TOKEN_ID_3], // Same token ID as burn for the first element + [MINT_AMOUNT, MINT_AMOUNT], // Mint amounts + ), + ) + .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') + .withArgs(TOKEN_ID_1); + }); + + it('should revert when burning and minting the same token ID in operatorBatchBurnAndMint (second element)', async function () { + const { + sandboxPasses, + admin, + operator, + user1, + TOKEN_ID_1, + TOKEN_ID_2, + TOKEN_ID_3, + MINT_AMOUNT, + } = await loadFixture(runCreateTestSetup); + + // First mint some tokens to the user + await sandboxPasses + .connect(admin) + .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); + await sandboxPasses + .connect(admin) + .adminMint(user1.address, TOKEN_ID_2, MINT_AMOUNT); + + // Should revert with SameTokenIdBurnAndMint for the second matching token ID + await expect( + sandboxPasses.connect(operator).operatorBatchBurnAndMint( + user1.address, + user1.address, + [TOKEN_ID_1, TOKEN_ID_2], // Burn token IDs + [MINT_AMOUNT, MINT_AMOUNT], // Burn amounts + [TOKEN_ID_3, TOKEN_ID_2], // Same token ID as burn for the second element + [MINT_AMOUNT, MINT_AMOUNT], // Mint amounts + ), + ) + .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') + .withArgs(TOKEN_ID_2); + }); + }); }); From 91500dfd12f024de1d928c76d2990fdb7d7569f4 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Wed, 26 Mar 2025 21:32:55 +0100 Subject: [PATCH 19/28] lint fix --- packages/game-passes/test/GamePasses.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 88b70d7ec0..41a8f8523a 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -304,6 +304,7 @@ describe('GamePasses', function () { ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidTreasuryWallet'); }); + it('should not allow updating treasury wallet to the same address as the contract', async function () { const {sandboxPasses, admin, TOKEN_ID_1} = await loadFixture(runCreateTestSetup); @@ -320,6 +321,7 @@ describe('GamePasses', function () { ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidTreasuryWallet'); }); + it('should not allow non-admin to update token configuration', async function () { const {sandboxPasses, user1, TOKEN_ID_1} = await loadFixture(runCreateTestSetup); From 8a46f4db973d1b417db43c4cb26b04c5d9ef5cc4 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Thu, 27 Mar 2025 10:01:25 +0100 Subject: [PATCH 20/28] Allow same token burn and mint --- packages/game-passes/contracts/GamePasses.sol | 27 ++-- packages/game-passes/test/GamePasses.test.ts | 145 ------------------ 2 files changed, 10 insertions(+), 162 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index f3bc718a99..21487868f2 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -284,8 +284,6 @@ contract GamePasses is error TransferabilityAlreadySet(uint256 tokenId); /// @dev Revert when sender of the transaction does not equal the caller value error InvalidSender(); - /// @dev Revert when burn and mint token IDs are the same - error SameTokenIdBurnAndMint(uint256 tokenId); /// @dev Revert when treasury wallet is the same as the contract address error InvalidTreasuryWallet(); @@ -570,16 +568,17 @@ contract GamePasses is uint256 mintTokenId, uint256 mintAmount ) external whenNotPaused onlyRole(OPERATOR_ROLE) { - if (burnTokenId == mintTokenId) { - revert SameTokenIdBurnAndMint(burnTokenId); - } - TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintTokenId]; + TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnTokenId]; if (!mintConfig.isConfigured) { revert TokenNotConfigured(mintTokenId); } + if (!burnConfig.isConfigured) { + revert TokenNotConfigured(burnTokenId); + } + _checkMaxSupply(mintTokenId, mintAmount); mintConfig.mintedPerWallet[mintTo] += mintAmount; @@ -625,21 +624,19 @@ contract GamePasses is revert ArrayLengthMismatch(); } - // Check for same token IDs in burn and mint operations - for (uint256 i; i < burnTokenIds.length; i++) { - if (burnTokenIds[i] == mintTokenIds[i]) { - revert SameTokenIdBurnAndMint(burnTokenIds[i]); - } - } - // Validate mint tokens and check max supply for (uint256 i; i < mintTokenIds.length; i++) { TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintTokenIds[i]]; + TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnTokenIds[i]]; if (!mintConfig.isConfigured) { revert TokenNotConfigured(mintTokenIds[i]); } + if (!burnConfig.isConfigured) { + revert TokenNotConfigured(burnTokenIds[i]); + } + _checkMaxSupply(mintTokenIds[i], mintAmounts[i]); _checkMaxPerWallet(mintTokenIds[i], mintTo, mintAmounts[i]); mintConfig.mintedPerWallet[mintTo] += mintAmounts[i]; @@ -685,10 +682,6 @@ contract GamePasses is revert InvalidSender(); } - if (burnId == mintId) { - revert SameTokenIdBurnAndMint(burnId); - } - TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnId]; if (!burnConfig.isConfigured) { revert TokenNotConfigured(burnId); diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 41a8f8523a..78a55c0aed 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -3183,149 +3183,4 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(SandboxPasses, 'InvalidPaymentToken'); }); }); - - describe('Burn and Mint Validations', function () { - it('should revert when burning and minting the same token ID in burnAndMint', async function () { - const { - sandboxPasses, - admin, - user1, - TOKEN_ID_1, - MINT_AMOUNT, - createBurnAndMintSignature, - } = await loadFixture(runCreateTestSetup); - - // First mint some tokens to the user - await sandboxPasses - .connect(admin) - .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); - - // Create signature for burning and minting the same token - const burnAmount = MINT_AMOUNT; - const mintAmount = MINT_AMOUNT; - const deadline = (await time.latest()) + 3600; - const signatureId = 12345; - - const signature = await createBurnAndMintSignature( - admin, - user1.address, - TOKEN_ID_1, // Same token ID for burn - burnAmount, - TOKEN_ID_1, // Same token ID for mint - mintAmount, - deadline, - signatureId, - ); - - // Should revert with SameTokenIdBurnAndMint - await expect( - sandboxPasses.connect(user1).burnAndMint( - user1.address, - TOKEN_ID_1, - burnAmount, - TOKEN_ID_1, // Same token ID - mintAmount, - deadline, - signature, - signatureId, - ), - ) - .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') - .withArgs(TOKEN_ID_1); - }); - - it('should revert when burning and minting the same token ID in operatorBurnAndMint', async function () { - const {sandboxPasses, admin, operator, user1, TOKEN_ID_1, MINT_AMOUNT} = - await loadFixture(runCreateTestSetup); - - // First mint some tokens to the user - await sandboxPasses - .connect(admin) - .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); - - // Should revert with SameTokenIdBurnAndMint - await expect( - sandboxPasses.connect(operator).operatorBurnAndMint( - user1.address, - user1.address, - TOKEN_ID_1, // Same token ID for burn - MINT_AMOUNT, - TOKEN_ID_1, // Same token ID for mint - MINT_AMOUNT, - ), - ) - .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') - .withArgs(TOKEN_ID_1); - }); - - it('should revert when burning and minting the same token ID in operatorBatchBurnAndMint', async function () { - const { - sandboxPasses, - admin, - operator, - user1, - TOKEN_ID_1, - TOKEN_ID_2, - TOKEN_ID_3, - MINT_AMOUNT, - } = await loadFixture(runCreateTestSetup); - - // First mint some tokens to the user - await sandboxPasses - .connect(admin) - .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); - await sandboxPasses - .connect(admin) - .adminMint(user1.address, TOKEN_ID_2, MINT_AMOUNT); - - // Should revert with SameTokenIdBurnAndMint for the first matching token ID - await expect( - sandboxPasses.connect(operator).operatorBatchBurnAndMint( - user1.address, - user1.address, - [TOKEN_ID_1, TOKEN_ID_2], // Burn token IDs - [MINT_AMOUNT, MINT_AMOUNT], // Burn amounts - [TOKEN_ID_1, TOKEN_ID_3], // Same token ID as burn for the first element - [MINT_AMOUNT, MINT_AMOUNT], // Mint amounts - ), - ) - .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') - .withArgs(TOKEN_ID_1); - }); - - it('should revert when burning and minting the same token ID in operatorBatchBurnAndMint (second element)', async function () { - const { - sandboxPasses, - admin, - operator, - user1, - TOKEN_ID_1, - TOKEN_ID_2, - TOKEN_ID_3, - MINT_AMOUNT, - } = await loadFixture(runCreateTestSetup); - - // First mint some tokens to the user - await sandboxPasses - .connect(admin) - .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); - await sandboxPasses - .connect(admin) - .adminMint(user1.address, TOKEN_ID_2, MINT_AMOUNT); - - // Should revert with SameTokenIdBurnAndMint for the second matching token ID - await expect( - sandboxPasses.connect(operator).operatorBatchBurnAndMint( - user1.address, - user1.address, - [TOKEN_ID_1, TOKEN_ID_2], // Burn token IDs - [MINT_AMOUNT, MINT_AMOUNT], // Burn amounts - [TOKEN_ID_3, TOKEN_ID_2], // Same token ID as burn for the second element - [MINT_AMOUNT, MINT_AMOUNT], // Mint amounts - ), - ) - .to.be.revertedWithCustomError(sandboxPasses, 'SameTokenIdBurnAndMint') - .withArgs(TOKEN_ID_2); - }); - }); }); From c2b8e88ace8122c081d69a51525e88c5055a6cb8 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Thu, 27 Mar 2025 11:55:59 +0100 Subject: [PATCH 21/28] Update the comment to prevent confusion --- packages/game-passes/contracts/GamePasses.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 4f9647944b..ce3fc2827e 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -1183,9 +1183,7 @@ contract GamePasses is * - Allows burns (to == address(0)) * - Checks transferability for regular transfers * @dev Reverts if: - * - Token is non-transferable AND - * - Sender is not whitelisted AND - * - Sender is not ADMIN_ROLE or OPERATOR_ROLE + * - Token is non-transferable AND sender is not whitelisted AND sender is not ADMIN_ROLE or OPERATOR_ROLE */ function _update( address from, From b250767430d029dbd815f4fa1c970b4d5280cc40 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Wed, 2 Apr 2025 20:42:44 +0200 Subject: [PATCH 22/28] Pre-calculate the storage slots --- packages/game-passes/contracts/GamePasses.sol | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 21487868f2..1150c8517d 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -113,12 +113,9 @@ contract GamePasses is } function _coreStorage() private pure returns (CoreStorage storage cs) { - bytes32 position = keccak256( - abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.CoreStorage"))) - 1) - ) & ~bytes32(uint256(0xff)); // solhint-disable-next-line no-inline-assembly assembly { - cs.slot := position + cs.slot := CORE_STORAGE_LOCATION } } @@ -129,12 +126,9 @@ contract GamePasses is } function _tokenStorage() private pure returns (TokenStorage storage ts) { - bytes32 position = keccak256( - abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.TokenStorage"))) - 1) - ) & ~bytes32(uint256(0xff)); // solhint-disable-next-line no-inline-assembly assembly { - ts.slot := position + ts.slot := TOKEN_STORAGE_LOCATION } } @@ -142,6 +136,14 @@ contract GamePasses is // Constants // ============================================================= + // keccak256(abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.CoreStorage"))) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant CORE_STORAGE_LOCATION = + 0xba0c4bc36712a57d2047a947603622e9142187f10a1421293cb6d7500dee6f00; + + // keccak256(abi.encode(uint256(keccak256(bytes("sandbox.game-passes.storage.TokenStorage"))) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant TOKEN_STORAGE_LOCATION = + 0x437f928739e2760da74c662888f938178fa33ad7fb16b9bdbb0b29abf5edec00; + /// @dev The role that is allowed to upgrade the contract and manage admin-level operations. bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); /// @dev The role that is allowed to sign minting operations From b5bb745581bee053d23c20fdf7703d61f60c5187 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Wed, 2 Apr 2025 20:53:16 +0200 Subject: [PATCH 23/28] Fix function ordering --- packages/game-passes/contracts/GamePasses.sol | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 1150c8517d..915efad3b4 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -941,6 +941,47 @@ contract GamePasses is } } + /** + * @notice Pauses all contract operations + * @dev Only callable by addresses with ADMIN_ROLE + * @dev When paused, prevents minting, burning, and transfers + * @dev Reverts if: + * - Caller doesn't have ADMIN_ROLE + * - Contract is already paused + */ + function pause() external onlyRole(ADMIN_ROLE) { + _pause(); + } + + /** + * @notice Unpauses all contract operations + * @dev Only callable by addresses with ADMIN_ROLE + * @dev Restores minting, burning, and transfer functionality + * @dev Reverts if: + * - Caller doesn't have ADMIN_ROLE + * - Contract is not paused + */ + function unpause() external onlyRole(ADMIN_ROLE) { + _unpause(); + } + + /** + * @notice Recover ERC20 tokens accidentally sent to the contract + * @param token The ERC20 token address to recover + * @param to The address to send recovered tokens to + * @param amount The amount of tokens to recover + * @dev Only callable by addresses with ADMIN_ROLE + * @dev Cannot recover the payment token if contract is not paused + */ + function recoverERC20(address token, address to, uint256 amount) external onlyRole(ADMIN_ROLE) { + if (token == _coreStorage().paymentToken && !paused()) { + revert PaymentTokenRecoveryNotAllowed(); + } + + SafeERC20.safeTransfer(IERC20(token), to, amount); + emit TokensRecovered(_msgSender(), token, to, amount); + } + /** * @notice Check if an address is whitelisted for token transfers * @param tokenId The token ID to check @@ -1022,30 +1063,6 @@ contract GamePasses is return _tokenStorage().tokenConfigs[tokenId].mintedPerWallet[wallet]; } - /** - * @notice Pauses all contract operations - * @dev Only callable by addresses with ADMIN_ROLE - * @dev When paused, prevents minting, burning, and transfers - * @dev Reverts if: - * - Caller doesn't have ADMIN_ROLE - * - Contract is already paused - */ - function pause() external onlyRole(ADMIN_ROLE) { - _pause(); - } - - /** - * @notice Unpauses all contract operations - * @dev Only callable by addresses with ADMIN_ROLE - * @dev Restores minting, burning, and transfer functionality - * @dev Reverts if: - * - Caller doesn't have ADMIN_ROLE - * - Contract is not paused - */ - function unpause() external onlyRole(ADMIN_ROLE) { - _unpause(); - } - /** * @notice Returns the current owner address of the contract * @dev This address may have special permissions beyond role-based access control @@ -1055,23 +1072,6 @@ contract GamePasses is return _coreStorage().internalOwner; } - /** - * @notice Recover ERC20 tokens accidentally sent to the contract - * @param token The ERC20 token address to recover - * @param to The address to send recovered tokens to - * @param amount The amount of tokens to recover - * @dev Only callable by addresses with ADMIN_ROLE - * @dev Cannot recover the payment token if contract is not paused - */ - function recoverERC20(address token, address to, uint256 amount) external onlyRole(ADMIN_ROLE) { - if (token == _coreStorage().paymentToken && !paused()) { - revert PaymentTokenRecoveryNotAllowed(); - } - - SafeERC20.safeTransfer(IERC20(token), to, amount); - emit TokensRecovered(_msgSender(), token, to, amount); - } - /** * @notice Returns the metadata URI for a specific token ID * @param tokenId ID of the token to get URI for From 7c607828ba7a9d155592e8b07b891ecde48dddf2 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Wed, 2 Apr 2025 21:13:41 +0200 Subject: [PATCH 24/28] Update naming of the variable and function --- packages/game-passes/contracts/GamePasses.sol | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 915efad3b4..02db22198b 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -38,7 +38,7 @@ contract GamePasses is bool isConfigured; bool transferable; address treasuryWallet; // specific treasury wallet for this token - uint256 maxSupply; // 0 for open edition + uint256 maxMintable; // 0 for open edition string metadata; uint256 maxPerWallet; // max tokens that can be minted per wallet uint256 totalMinted; // total tokens already minted @@ -193,15 +193,15 @@ contract GamePasses is /// @param caller Address that initiated the token configuration /// @param tokenId ID of the token being configured /// @param transferable Whether the token can be transferred - /// @param maxSupply Maximum supply for this token (0 means unlimited) - /// @param maxPerWallet Maximum number of tokens a single wallet can mint (0 means unlimited) + /// @param maxMintable Maximum copies to be minted for this token (type(uint256).max means unlimited) + /// @param maxPerWallet Maximum number of tokens a single wallet can mint (type(uint256).max means unlimited) /// @param metadata Token-specific metadata string /// @param treasuryWallet Address where payments for this token will be sent event TokenConfigured( address indexed caller, uint256 indexed tokenId, bool transferable, - uint256 maxSupply, + uint256 maxMintable, uint256 maxPerWallet, string metadata, address treasuryWallet @@ -209,14 +209,14 @@ contract GamePasses is /// @notice Emitted when a token configuration is updated. /// @param caller Address that initiated the token configuration update /// @param tokenId ID of the token being updated - /// @param maxSupply New maximum supply for this token (0 means unlimited) - /// @param maxPerWallet New maximum number of tokens a single wallet can mint (0 means unlimited) + /// @param maxMintable New Maximum copies to be minted for this token (type(uint256).max means unlimited) + /// @param maxPerWallet New maximum number of tokens a single wallet can mint (type(uint256).max means unlimited) /// @param metadata New token-specific metadata string /// @param treasuryWallet New address where payments for this token will be sent event TokenConfigUpdated( address indexed caller, uint256 indexed tokenId, - uint256 maxSupply, + uint256 maxMintable, uint256 maxPerWallet, string metadata, address treasuryWallet @@ -250,8 +250,8 @@ contract GamePasses is /// @dev Revert when trying to mint a token that is not configured error TokenNotConfigured(uint256 tokenId); - /// @dev Revert when trying to mint more tokens than the max supply - error MaxSupplyExceeded(uint256 tokenId); + /// @dev Revert when trying to mint more tokens than the max mintable + error MaxMintableExceeded(uint256 tokenId); /// @dev Revert when token is already configured error TokenAlreadyConfigured(uint256 tokenId); /// @dev Revert when array lengths mismatch @@ -264,8 +264,8 @@ contract GamePasses is error InvalidSigner(); /// @dev Revert when signature already used error SignatureAlreadyUsed(uint256 signatureId); - /// @dev Revert when max supply below current supply - error MaxSupplyBelowCurrentSupply(uint256 tokenId); + /// @dev Revert when max mintable below current total minted for a token + error MaxMintableBelowCurrentMinted(uint256 tokenId); /// @dev Revert when transfer not allowed error TransferNotAllowed(uint256 tokenId); /// @dev Revert when exceeds max per wallet @@ -364,14 +364,14 @@ contract GamePasses is * @param price Price to pay in payment token units * @param deadline Timestamp after which the signature becomes invalid * @param signature EIP-712 signature from an authorized signer - * @dev Verifies the signature, checks supply limits, processes payment, and mints tokens + * @dev Verifies the signature, checks mint limits, processes payment, and mints tokens * @dev Updates the per-wallet minting count and transfers payment to the appropriate treasury * @dev Reverts if: * - Contract is paused * - Caller is not the same as msg.sender and its not an approveAndCall operation through SAND contract * - Token is not configured * - Signature is invalid or expired - * - Max supply would be exceeded + * - Max mintable would be exceeded * - Max per wallet would be exceeded * - Payment transfer fails */ @@ -418,7 +418,7 @@ contract GamePasses is * - Batch size exceeds MAX_BATCH_SIZE * - Any token is not configured * - Signature is invalid or expired - * - Any max supply would be exceeded + * - Any max mintable would be exceeded * - Any max per wallet would be exceeded * - Any payment transfer fails */ @@ -452,11 +452,11 @@ contract GamePasses is * @param tokenId ID of the token to mint * @param amount Number of tokens to mint * @dev Only callable by addresses with ADMIN_ROLE - * @dev Still respects max supply limits but bypasses per-wallet limits + * @dev Still respects max mintable limits but bypasses per-wallet limits * @dev Reverts if: * - Caller doesn't have ADMIN_ROLE * - Token is not configured - * - Max supply would be exceeded + * - Max mintable would be exceeded */ function adminMint(address to, uint256 tokenId, uint256 amount) external onlyRole(ADMIN_ROLE) { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; @@ -465,7 +465,7 @@ contract GamePasses is revert TokenNotConfigured(tokenId); } - _checkMaxSupply(tokenId, amount); + _updateAndCheckTotalMinted(tokenId, amount); config.mintedPerWallet[to] += amount; _mint(to, tokenId, amount, ""); @@ -477,13 +477,13 @@ contract GamePasses is * @param ids Array of token IDs to mint * @param amounts Array of amounts to mint for each token ID * @dev Only callable by addresses with ADMIN_ROLE - * @dev Still respects max supply limits but bypasses per-wallet limits + * @dev Still respects max mintable limits but bypasses per-wallet limits * @dev All array parameters must be the same length * @dev Reverts if: * - Caller doesn't have ADMIN_ROLE * - Array lengths don't match * - Any token is not configured - * - Any max supply would be exceeded + * - Any max mintable would be exceeded */ function adminBatchMint( address to, @@ -500,7 +500,7 @@ contract GamePasses is if (!config.isConfigured) { revert TokenNotConfigured(ids[i]); } - _checkMaxSupply(ids[i], amounts[i]); + _updateAndCheckTotalMinted(ids[i], amounts[i]); config.mintedPerWallet[to] += amounts[i]; } _mintBatch(to, ids, amounts, ""); @@ -512,7 +512,7 @@ contract GamePasses is * @param ids Array of token IDs to mint * @param amounts Array of amounts to mint * @dev Only callable by addresses with ADMIN_ROLE - * @dev Still respects max supply limits but bypasses per-wallet limits + * @dev Still respects max mintable limits but bypasses per-wallet limits * @dev All array parameters must be the same length * @dev Each index in the arrays corresponds to a single mint operation: * to[i] receives amounts[i] of token ids[i] @@ -520,7 +520,7 @@ contract GamePasses is * - Caller doesn't have ADMIN_ROLE * - Array lengths don't match * - Any token is not configured - * - Any max supply would be exceeded + * - Any max mintable would be exceeded */ function adminMultiRecipientMint( address[] calldata to, @@ -538,7 +538,7 @@ contract GamePasses is revert TokenNotConfigured(ids[i]); } - _checkMaxSupply(ids[i], amounts[i]); + _updateAndCheckTotalMinted(ids[i], amounts[i]); config.mintedPerWallet[to[i]] += amounts[i]; _mint(to[i], ids[i], amounts[i], ""); } @@ -559,7 +559,7 @@ contract GamePasses is * - Contract is paused * - Caller doesn't have OPERATOR_ROLE * - Mint token is not configured - * - Max supply would be exceeded for mint token + * - Max mintable would be exceeded for mint token * - Burn operation fails (insufficient balance) */ function operatorBurnAndMint( @@ -581,7 +581,7 @@ contract GamePasses is revert TokenNotConfigured(burnTokenId); } - _checkMaxSupply(mintTokenId, mintAmount); + _updateAndCheckTotalMinted(mintTokenId, mintAmount); mintConfig.mintedPerWallet[mintTo] += mintAmount; _burn(burnFrom, burnTokenId, burnAmount); @@ -607,7 +607,7 @@ contract GamePasses is * - Caller doesn't have OPERATOR_ROLE * - Array lengths don't match * - Any mint token is not configured - * - Any max supply would be exceeded + * - Any max mintable would be exceeded * - Any burn operation fails (insufficient balance) */ function operatorBatchBurnAndMint( @@ -626,7 +626,7 @@ contract GamePasses is revert ArrayLengthMismatch(); } - // Validate mint tokens and check max supply + // Validate mint tokens and check max mintable for (uint256 i; i < mintTokenIds.length; i++) { TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintTokenIds[i]]; TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnTokenIds[i]]; @@ -639,7 +639,7 @@ contract GamePasses is revert TokenNotConfigured(burnTokenIds[i]); } - _checkMaxSupply(mintTokenIds[i], mintAmounts[i]); + _updateAndCheckTotalMinted(mintTokenIds[i], mintAmounts[i]); _checkMaxPerWallet(mintTokenIds[i], mintTo, mintAmounts[i]); mintConfig.mintedPerWallet[mintTo] += mintAmounts[i]; } @@ -666,7 +666,7 @@ contract GamePasses is * - Burn token is not configured * - Mint token is not configured * - Signature is invalid or expired - * - Max supply would be exceeded for mint token + * - Max mintable would be exceeded for mint token * - Burn operation fails (insufficient balance) */ function burnAndMint( @@ -706,7 +706,7 @@ contract GamePasses is verifyBurnAndMintSignature(request, signature); - _checkMaxSupply(mintId, mintAmount); + _updateAndCheckTotalMinted(mintId, mintAmount); mintConfig.mintedPerWallet[caller] += mintAmount; _burn(caller, burnId, burnAmount); @@ -777,7 +777,7 @@ contract GamePasses is * @notice Configure a new token with its properties and restrictions * @param tokenId The token ID to configure * @param transferable Whether the token can be transferred between users - * @param maxSupply Maximum supply (0 for disabled, type(uint256).max for unlimited/open edition) + * @param maxMintable Maximum copies to be minted (0 for disabled, type(uint256).max for unlimited/open edition) * @param maxPerWallet Maximum tokens that can be minted per wallet (0 for disabled, type(uint256).max for unlimited) * @param metadata Token metadata string (typically IPFS hash or other identifier) * @param treasuryWallet Specific treasury wallet for this token (or address(0) for default) @@ -791,7 +791,7 @@ contract GamePasses is function configureToken( uint256 tokenId, bool transferable, - uint256 maxSupply, + uint256 maxMintable, uint256 maxPerWallet, string calldata metadata, address treasuryWallet @@ -808,32 +808,32 @@ contract GamePasses is config.isConfigured = true; config.transferable = transferable; - config.maxSupply = maxSupply; + config.maxMintable = maxMintable; config.maxPerWallet = maxPerWallet; config.metadata = metadata; config.treasuryWallet = treasuryWallet; - emit TokenConfigured(_msgSender(), tokenId, transferable, maxSupply, maxPerWallet, metadata, treasuryWallet); + emit TokenConfigured(_msgSender(), tokenId, transferable, maxMintable, maxPerWallet, metadata, treasuryWallet); } /** * @notice Update existing token configuration * @param tokenId The token ID to update - * @param maxSupply New maximum supply (0 for disabled, type(uint256).max for unlimited/open edition) + * @param maxMintable New Maximum copies to be minted (0 for disabled, type(uint256).max for unlimited/open edition) * @param maxPerWallet New maximum tokens per wallet (0 for disabled, type(uint256).max for unlimited) * @param metadata New metadata string (typically IPFS hash) * @param treasuryWallet New treasury wallet (or address(0) for default) * @dev Only callable by addresses with ADMIN_ROLE * @dev Token must be already configured - * @dev Cannot decrease maxSupply below current supply + * @dev Cannot decrease maxMintable below current total minted * @dev Reverts if: * - Caller doesn't have ADMIN_ROLE * - Token is not configured - * - New maxSupply is less than current supply + * - New maxMintable is less than current total minted */ function updateTokenConfig( uint256 tokenId, - uint256 maxSupply, + uint256 maxMintable, uint256 maxPerWallet, string calldata metadata, address treasuryWallet @@ -844,21 +844,20 @@ contract GamePasses is revert TokenNotConfigured(tokenId); } - uint256 currentSupply = totalSupply(tokenId); - if (maxSupply < currentSupply) { - revert MaxSupplyBelowCurrentSupply(tokenId); + if (maxMintable < config.totalMinted) { + revert MaxMintableBelowCurrentMinted(tokenId); } if (treasuryWallet == address(this)) { revert InvalidTreasuryWallet(); } - config.maxSupply = maxSupply; + config.maxMintable = maxMintable; config.maxPerWallet = maxPerWallet; config.metadata = metadata; config.treasuryWallet = treasuryWallet; - emit TokenConfigUpdated(_msgSender(), tokenId, maxSupply, maxPerWallet, metadata, treasuryWallet); + emit TokenConfigUpdated(_msgSender(), tokenId, maxMintable, maxPerWallet, metadata, treasuryWallet); } /** @@ -1032,7 +1031,7 @@ contract GamePasses is * @param tokenId The token ID to get configuration for * @return isConfigured Whether the token is configured * @return transferable Whether the token is transferable - * @return maxSupply Maximum supply for the token (0 for unlimited) + * @return maxMintable Maximum copies to be minted for the token (type(uint256).max for unlimited) * @return metadata Token metadata string * @return maxPerWallet Maximum tokens per wallet * @return treasuryWallet Treasury wallet for the token @@ -1045,7 +1044,7 @@ contract GamePasses is return ( config.isConfigured, config.transferable, - config.maxSupply, + config.maxMintable, config.metadata, config.maxPerWallet, config.treasuryWallet, @@ -1276,7 +1275,7 @@ contract GamePasses is verifySignature(request, signature); _checkMaxPerWallet(request.tokenId, request.caller, request.amount); - _checkMaxSupply(request.tokenId, request.amount); + _updateAndCheckTotalMinted(request.tokenId, request.amount); config.mintedPerWallet[request.caller] += request.amount; @@ -1312,7 +1311,7 @@ contract GamePasses is } _checkMaxPerWallet(request.tokenIds[i], request.caller, request.amounts[i]); - _checkMaxSupply(request.tokenIds[i], request.amounts[i]); + _updateAndCheckTotalMinted(request.tokenIds[i], request.amounts[i]); config.mintedPerWallet[request.caller] += request.amounts[i]; @@ -1365,20 +1364,21 @@ contract GamePasses is } /** - * @notice Helper function to check if minting would exceed max supply + * @notice Updates the total minted count and checks if it would exceed max mintable * @param tokenId The token ID to check * @param amount The amount to mint - * @dev Used internally before any mint operation + * @dev This function both increments totalMinted and checks against maxMintable limit + * @dev MaxMintable tracks the total number of tokens that can ever be minted, regardless of burns * @dev Reverts if: - * - Token has maxSupply = 0 (minting disabled) or - * - Current supply + amount would exceed max supply + * - Token has maxMintable = 0 (minting disabled) or + * - Total minted (including any previously burned tokens) would exceed maxMintable */ - function _checkMaxSupply(uint256 tokenId, uint256 amount) private { + function _updateAndCheckTotalMinted(uint256 tokenId, uint256 amount) private { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; config.totalMinted += amount; - if (config.maxSupply != type(uint256).max && config.totalMinted > config.maxSupply) { - revert MaxSupplyExceeded(tokenId); + if (config.maxMintable != type(uint256).max && config.totalMinted > config.maxMintable) { + revert MaxMintableExceeded(tokenId); } } From 190726b76c90f2b2b26cb9c6a0b503cb683808f7 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Thu, 3 Apr 2025 09:41:06 +0200 Subject: [PATCH 25/28] Update tests --- packages/game-passes/test/GamePasses.test.ts | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 78a55c0aed..7b57eb1b43 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -163,7 +163,7 @@ describe('GamePasses', function () { expect(tokenConfig[5]).to.equal(user2.address); }); - it('should not allow decreasing max supply below current supply', async function () { + it('should not allow decreasing max mintable below current total minted', async function () { const {sandboxPasses, admin, user1, TOKEN_ID_1, TOKEN_METADATA} = await loadFixture(runCreateTestSetup); @@ -172,7 +172,7 @@ describe('GamePasses', function () { .connect(admin) .adminMint(user1.address, TOKEN_ID_1, 50); - // Try to update max supply to below current supply + // Try to update max mintable to below current minted await expect( sandboxPasses .connect(admin) @@ -185,7 +185,7 @@ describe('GamePasses', function () { ), ).to.be.revertedWithCustomError( sandboxPasses, - 'MaxSupplyBelowCurrentSupply', + 'MaxMintableBelowCurrentMinted', ); }); @@ -454,7 +454,7 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(sandboxPasses, 'TokenNotConfigured'); }); - it('should not allow exceeding max supply', async function () { + it('should not allow exceeding max mintable', async function () { const {sandboxPasses, admin, TOKEN_ID_1, MAX_SUPPLY} = await loadFixture(runCreateTestSetup); @@ -462,7 +462,7 @@ describe('GamePasses', function () { sandboxPasses .connect(admin) .adminMint(admin.address, TOKEN_ID_1, MAX_SUPPLY + 1), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); it('should not allow non-admin to mint tokens', async function () { @@ -515,26 +515,26 @@ describe('GamePasses', function () { ); }); - it('should not allow adminBatchMint to exceed max supply with duplicate token IDs', async function () { + it('should not allow adminBatchMint to exceed max mintable with duplicate token IDs', async function () { const {sandboxPasses, admin, TOKEN_ID_1} = await loadFixture(runCreateTestSetup); - // Let's assume TOKEN_ID_1 has a max supply of 100 (from test setup) + // Let's assume TOKEN_ID_1 has a max mintable of 100 (from test setup) // First mint 90 tokens await sandboxPasses .connect(admin) .adminMint(admin.address, TOKEN_ID_1, 90); // Now try to mint the same token ID twice in a batch (5 + 6 = 11) - // This would exceed the max supply of 100 (90 + 11 > 100) + // This would exceed the max mintable of 100 (90 + 11 > 100) await expect( sandboxPasses .connect(admin) .adminBatchMint(admin.address, [TOKEN_ID_1, TOKEN_ID_1], [5, 6]), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); - it('should not allow adminMultiRecipientMint to exceed max supply with duplicate token IDs', async function () { + it('should not allow adminMultiRecipientMint to exceed max mintable with duplicate token IDs', async function () { const {sandboxPasses, admin, user1, TOKEN_ID_1} = await loadFixture(runCreateTestSetup); @@ -544,7 +544,7 @@ describe('GamePasses', function () { .adminMint(admin.address, TOKEN_ID_1, 90); // Now try to mint the same token ID to different recipients (6 + 5 = 11) - // This would exceed the max supply of 100 (90 + 11 > 100) + // This would exceed the max mintable of 100 (90 + 11 > 100) await expect( sandboxPasses .connect(admin) @@ -553,7 +553,7 @@ describe('GamePasses', function () { [TOKEN_ID_1, TOKEN_ID_1], [6, 5], ), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); }); @@ -1385,7 +1385,7 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); - it('should not allow batchMint to exceed max supply with duplicate token IDs', async function () { + it('should not allow batchMint to exceed max mintable with duplicate token IDs', async function () { const { sandboxPasses, signer, @@ -1422,7 +1422,7 @@ describe('GamePasses', function () { ); // Try to batch mint the same token ID twice (6 + 5 = 11) - // This would exceed the max supply of 100 (90 + 11 > 100) + // This would exceed the max mintable of 100 (90 + 11 > 100) await expect( sandboxPasses .connect(user1) @@ -1435,7 +1435,7 @@ describe('GamePasses', function () { signature, signatureId, ), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); it('should allow unlimited mints when maxPerWallet is type(uint256).max', async function () { @@ -1450,7 +1450,7 @@ describe('GamePasses', function () { // Configure a new token with maxPerWallet = type(uint256).max (unlimited) const UNLIMITED_TOKEN_ID = 9999; - const LARGE_MAX_SUPPLY = 1000; // Just to make sure we don't hit max supply + const LARGE_MAX_SUPPLY = 1000; // Just to make sure we don't hit max mintable await sandboxPasses.connect(admin).configureToken( UNLIMITED_TOKEN_ID, @@ -1660,7 +1660,7 @@ describe('GamePasses', function () { signature, signatureId, ), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); it('should allow unlimited supply when maxSupply is type(uint256).max', async function () { @@ -1961,7 +1961,7 @@ describe('GamePasses', function () { expect(mintedPerWallet1).to.equal(3); }); - it('should not allow operatorBatchBurnAndMint to exceed max supply with duplicate token IDs', async function () { + it('should not allow operatorBatchBurnAndMint to exceed max mintable with duplicate token IDs', async function () { const {sandboxPasses, operator, admin, user1, TOKEN_ID_1, TOKEN_ID_2} = await loadFixture(runCreateTestSetup); @@ -1976,7 +1976,7 @@ describe('GamePasses', function () { .adminMint(admin.address, TOKEN_ID_1, 90); // Try to mint the same token ID twice in a batch mint (6 + 5 = 11) - // This would exceed the max supply of 100 (90 + 11 > 100) + // This would exceed the max mintable of 100 (90 + 11 > 100) await expect( sandboxPasses .connect(operator) @@ -1988,7 +1988,7 @@ describe('GamePasses', function () { [TOKEN_ID_1, TOKEN_ID_1], [6, 5], ), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); it('should not allow operatorBatchBurnAndMint to exceed max per wallet with duplicate token IDs', async function () { From c9228089f6940edc44b1dfc9cce1c496e00beae2 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Thu, 3 Apr 2025 09:43:39 +0200 Subject: [PATCH 26/28] Update the comments --- packages/game-passes/test/GamePasses.test.ts | 38 ++++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/game-passes/test/GamePasses.test.ts b/packages/game-passes/test/GamePasses.test.ts index 7b57eb1b43..929ce1a658 100644 --- a/packages/game-passes/test/GamePasses.test.ts +++ b/packages/game-passes/test/GamePasses.test.ts @@ -140,7 +140,7 @@ describe('GamePasses', function () { await expect( sandboxPasses.connect(admin).updateTokenConfig( TOKEN_ID_1, - 200, // new max supply + 200, // new max mintable 15, // new max per wallet 'ipfs://QmUpdated', user2.address, @@ -329,7 +329,7 @@ describe('GamePasses', function () { await expect( sandboxPasses.connect(user1).updateTokenConfig( TOKEN_ID_1, - 200, // new max supply + 200, // new max mintable 15, // new max per wallet 'ipfs://QmUpdated', user1.address, @@ -909,7 +909,7 @@ describe('GamePasses', function () { await sandboxPasses.connect(admin).configureToken( i, true, // transferable - 100, // max supply + 100, // max mintable 10, // max per wallet `ipfs://token${i}`, // metadata ethers.ZeroAddress, // use default treasury @@ -986,7 +986,7 @@ describe('GamePasses', function () { await sandboxPasses.connect(admin).configureToken( i, true, // transferable - 100, // max supply + 100, // max mintable 10, // max per wallet `ipfs://token${i}`, // metadata ethers.ZeroAddress, // use default treasury @@ -1455,7 +1455,7 @@ describe('GamePasses', function () { await sandboxPasses.connect(admin).configureToken( UNLIMITED_TOKEN_ID, true, // transferable - LARGE_MAX_SUPPLY, // max supply + LARGE_MAX_SUPPLY, // max mintable ethers.MaxUint256, // maxPerWallet = type(uint256).max (unlimited) 'ipfs://unlimited-token', // metadata ethers.ZeroAddress, // use default treasury @@ -1563,7 +1563,7 @@ describe('GamePasses', function () { await sandboxPasses.connect(admin).configureToken( DISABLED_TOKEN_ID, true, // transferable - LARGE_MAX_SUPPLY, // max supply + LARGE_MAX_SUPPLY, // max mintable 0, // maxPerWallet = 0 (disabled) 'ipfs://disabled-token', // metadata ethers.ZeroAddress, // use default treasury @@ -1605,7 +1605,7 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(sandboxPasses, 'ExceedsMaxPerWallet'); }); - it('should reject mints when maxSupply is 0 (disabled)', async function () { + it('should reject mints when maxMintable is 0 (disabled)', async function () { const { sandboxPasses, signer, @@ -1615,15 +1615,15 @@ describe('GamePasses', function () { createMintSignature, } = await loadFixture(runCreateTestSetup); - // Configure a new token with maxSupply = 0 (disabled) + // Configure a new token with maxMintable = 0 (disabled) const DISABLED_TOKEN_ID = 7777; await sandboxPasses.connect(admin).configureToken( DISABLED_TOKEN_ID, true, // transferable - 0, // maxSupply = 0 (disabled) + 0, // maxMintable = 0 (disabled) 10, // maxPerWallet = 10 - 'ipfs://disabled-supply-token', // metadata + 'ipfs://disabled-mintable-token', // metadata ethers.ZeroAddress, // use default treasury ); @@ -1647,7 +1647,7 @@ describe('GamePasses', function () { signatureId, ); - // Mint should be rejected because maxSupply is 0 (disabled) + // Mint should be rejected because maxMintable is 0 (disabled) await expect( sandboxPasses .connect(user1) @@ -1663,7 +1663,7 @@ describe('GamePasses', function () { ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); - it('should allow unlimited supply when maxSupply is type(uint256).max', async function () { + it('should allow unlimited mints when maxMintable is type(uint256).max', async function () { const { sandboxPasses, signer, @@ -1673,16 +1673,16 @@ describe('GamePasses', function () { createMintSignature, } = await loadFixture(runCreateTestSetup); - // Configure a new token with maxSupply = type(uint256).max (unlimited) + // Configure a new token with maxMintable = type(uint256).max (unlimited) const UNLIMITED_TOKEN_ID = 6666; const NORMAL_MAX_PER_WALLET = 50; await sandboxPasses.connect(admin).configureToken( UNLIMITED_TOKEN_ID, true, // transferable - ethers.MaxUint256, // maxSupply = type(uint256).max (unlimited) + ethers.MaxUint256, // maxMintable = type(uint256).max (unlimited) NORMAL_MAX_PER_WALLET, // maxPerWallet - 'ipfs://unlimited-supply-token', // metadata + 'ipfs://unlimited-mintable-token', // metadata ethers.ZeroAddress, // use default treasury ); @@ -1706,7 +1706,7 @@ describe('GamePasses', function () { signatureId, ); - // Mint should succeed with unlimited supply + // Mint should succeed with unlimited mintable await sandboxPasses .connect(user1) .mint( @@ -2147,7 +2147,7 @@ describe('GamePasses', function () { await sandboxPasses.connect(admin).configureToken( TOKEN_ID_3, true, // transferable - 100, // max supply + 100, // max mintable 10, // max per wallet `ipfs://token${TOKEN_ID_3}`, // metadata ethers.ZeroAddress, // use default treasury @@ -2264,7 +2264,7 @@ describe('GamePasses', function () { await sandboxPasses.connect(admin).configureToken( TOKEN_ID_3, true, // transferable - 100, // max supply + 100, // max mintable 10, // max per wallet `ipfs://token${TOKEN_ID_3}`, // metadata ethers.ZeroAddress, // use default treasury @@ -2873,7 +2873,7 @@ describe('GamePasses', function () { await expect( sandboxPasses.connect(admin).updateTokenConfig( TOKEN_ID_1, - 200, // new max supply + 200, // new max mintable 15, // new max per wallet 'ipfs://QmUpdated', user2.address, From eec49f6155d45378605fbb07bea732a647c5f782 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Tue, 8 Apr 2025 09:22:07 +0200 Subject: [PATCH 27/28] Mark the signature verification functions internal --- packages/game-passes/contracts/GamePasses.sol | 164 +++++++++--------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index 02db22198b..b461965e9a 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -1091,88 +1091,6 @@ contract GamePasses is return _tokenStorage().tokenConfigs[tokenId].isConfigured; } - /** - * @notice Verify signature for mint operation using EIP-712 - * @param request The MintRequest struct containing all mint parameters - * @param signature The EIP-712 signature to verify - * @dev Public view function that can be used to verify signatures off-chain - * @dev Validates the signature against the MINT_TYPEHASH and DOMAIN_SEPARATOR - * @dev Reverts if: - * - Signature has expired - * - Signature is invalid - * - Signer doesn't have SIGNER_ROLE - */ - function verifySignature(MintRequest memory request, bytes memory signature) public { - bytes32 structHash = keccak256( - abi.encode( - MINT_TYPEHASH, - request.caller, - request.tokenId, - request.amount, - request.price, - request.deadline, - request.signatureId - ) - ); - - _verifySignature(structHash, signature, request.deadline, request.signatureId); - } - - /** - * @notice Verify signature for burn and mint operation using EIP-712 - * @param request The BurnAndMintRequest struct containing all operation parameters - * @param signature The EIP-712 signature to verify - * @dev Public view function that can be used to verify signatures off-chain - * @dev Validates the signature against the BURN_AND_MINT_TYPEHASH and DOMAIN_SEPARATOR - * @dev Reverts if: - * - Signature has expired - * - Signature is invalid - * - Signer doesn't have SIGNER_ROLE - */ - function verifyBurnAndMintSignature(BurnAndMintRequest memory request, bytes memory signature) public { - bytes32 structHash = keccak256( - abi.encode( - BURN_AND_MINT_TYPEHASH, - request.caller, - request.burnId, - request.burnAmount, - request.mintId, - request.mintAmount, - request.deadline, - request.signatureId - ) - ); - - _verifySignature(structHash, signature, request.deadline, request.signatureId); - } - - /** - * @notice Verify signature for batch mint operation using EIP-712 - * @param request The BatchMintRequest struct containing all batch mint parameters - * @param signature The EIP-712 signature to verify - * @dev Public view function that can be used to verify batch signatures off-chain - * @dev Validates the signature against the BATCH_MINT_TYPEHASH and DOMAIN_SEPARATOR - * @dev Reverts if: - * - Signature has expired - * - Signature is invalid - * - Signer doesn't have SIGNER_ROLE - */ - function verifyBatchSignature(BatchMintRequest memory request, bytes memory signature) public { - bytes32 structHash = keccak256( - abi.encode( - BATCH_MINT_TYPEHASH, - request.caller, - keccak256(abi.encodePacked(request.tokenIds)), - keccak256(abi.encodePacked(request.amounts)), - keccak256(abi.encodePacked(request.prices)), - request.deadline, - request.signatureId - ) - ); - - _verifySignature(structHash, signature, request.deadline, request.signatureId); - } - /** * @notice Checks if contract implements various interfaces * @param interfaceId The interface identifier to check @@ -1260,6 +1178,88 @@ contract GamePasses is return ERC2771HandlerUpgradeable._msgData(); } + /** + * @notice Verify signature for mint operation using EIP-712 + * @param request The MintRequest struct containing all mint parameters + * @param signature The EIP-712 signature to verify + * @dev Internal function that can be used to verify signatures off-chain + * @dev Validates the signature against the MINT_TYPEHASH and DOMAIN_SEPARATOR + * @dev Reverts if: + * - Signature has expired + * - Signature is invalid + * - Signer doesn't have SIGNER_ROLE + */ + function verifySignature(MintRequest memory request, bytes memory signature) internal { + bytes32 structHash = keccak256( + abi.encode( + MINT_TYPEHASH, + request.caller, + request.tokenId, + request.amount, + request.price, + request.deadline, + request.signatureId + ) + ); + + _verifySignature(structHash, signature, request.deadline, request.signatureId); + } + + /** + * @notice Verify signature for burn and mint operation using EIP-712 + * @param request The BurnAndMintRequest struct containing all operation parameters + * @param signature The EIP-712 signature to verify + * @dev Internal function that can be used to verify signatures off-chain + * @dev Validates the signature against the BURN_AND_MINT_TYPEHASH and DOMAIN_SEPARATOR + * @dev Reverts if: + * - Signature has expired + * - Signature is invalid + * - Signer doesn't have SIGNER_ROLE + */ + function verifyBurnAndMintSignature(BurnAndMintRequest memory request, bytes memory signature) internal { + bytes32 structHash = keccak256( + abi.encode( + BURN_AND_MINT_TYPEHASH, + request.caller, + request.burnId, + request.burnAmount, + request.mintId, + request.mintAmount, + request.deadline, + request.signatureId + ) + ); + + _verifySignature(structHash, signature, request.deadline, request.signatureId); + } + + /** + * @notice Verify signature for batch mint operation using EIP-712 + * @param request The BatchMintRequest struct containing all batch mint parameters + * @param signature The EIP-712 signature to verify + * @dev Internal function that can be used to verify batch signatures off-chain + * @dev Validates the signature against the BATCH_MINT_TYPEHASH and DOMAIN_SEPARATOR + * @dev Reverts if: + * - Signature has expired + * - Signature is invalid + * - Signer doesn't have SIGNER_ROLE + */ + function verifyBatchSignature(BatchMintRequest memory request, bytes memory signature) internal { + bytes32 structHash = keccak256( + abi.encode( + BATCH_MINT_TYPEHASH, + request.caller, + keccak256(abi.encodePacked(request.tokenIds)), + keccak256(abi.encodePacked(request.amounts)), + keccak256(abi.encodePacked(request.prices)), + request.deadline, + request.signatureId + ) + ); + + _verifySignature(structHash, signature, request.deadline, request.signatureId); + } + /** * @dev Internal helper function to process a single mint operation * @param request The MintRequest struct containing all mint parameters From a4d8f9f8768bb7aa81b124bc8141d0f8e8f77d40 Mon Sep 17 00:00:00 2001 From: wojciech-turek Date: Tue, 8 Apr 2025 09:25:03 +0200 Subject: [PATCH 28/28] Remove redundant check --- packages/game-passes/contracts/GamePasses.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index b461965e9a..830103110e 100644 --- a/packages/game-passes/contracts/GamePasses.sol +++ b/packages/game-passes/contracts/GamePasses.sol @@ -1377,7 +1377,7 @@ contract GamePasses is TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; config.totalMinted += amount; - if (config.maxMintable != type(uint256).max && config.totalMinted > config.maxMintable) { + if (config.totalMinted > config.maxMintable) { revert MaxMintableExceeded(tokenId); } }