diff --git a/packages/game-passes/contracts/GamePasses.sol b/packages/game-passes/contracts/GamePasses.sol index ef32810193..18bd888e0f 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"; @@ -14,11 +14,12 @@ 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. + * @custom:security-contact contact-blockchain@sandbox.game */ -contract SandboxPasses1155Upgradeable is +contract GamePasses is Initializable, ERC2771HandlerUpgradeable, AccessControlUpgradeable, @@ -28,38 +29,6 @@ contract SandboxPasses1155Upgradeable is { using Strings for uint256; - // ============================================================= - // Events - // ============================================================= - - /// @notice Emitted when the base URI is updated. - event BaseURISet(address indexed caller, string oldURI, string newURI); - /// @notice Emitted when a token is configured. - 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. - 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. - event TransferabilityUpdated(address indexed caller, uint256 indexed tokenId, bool transferable); - /// @notice Emitted when transfer whitelist is updated. - event TransferWhitelistUpdated(address indexed caller, uint256 indexed tokenId, address[] accounts, bool allowed); - /// @notice Emitted when tokens are recovered from the contract. - event TokensRecovered(address indexed caller, address token, address recipient, uint256 amount); - // ============================================================= // Structs // ============================================================= @@ -68,13 +37,13 @@ contract SandboxPasses1155Upgradeable is struct TokenConfig { bool isConfigured; bool transferable; - uint256 maxSupply; // 0 for open edition + address treasuryWallet; // specific treasury wallet for this token + uint256 maxMintable; // 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 + 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 @@ -85,7 +54,7 @@ contract SandboxPasses1155Upgradeable is uint256 mintId; uint256 mintAmount; uint256 deadline; - uint256 nonce; + uint256 signatureId; } /// @dev Struct to hold mint request @@ -95,7 +64,7 @@ contract SandboxPasses1155Upgradeable is uint256 amount; uint256 price; uint256 deadline; - uint256 nonce; + uint256 signatureId; } /// @dev Struct to hold batch mint request @@ -105,58 +74,61 @@ contract SandboxPasses1155Upgradeable is uint256[] amounts; uint256[] prices; uint256 deadline; - uint256 nonce; + 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 // ============================================================= + /// @custom:storage-location erc7201:sandbox.game-passes.storage.CoreStorage struct CoreStorage { // Base URI for computing {uri} string baseURI; // Default treasury wallet address defaultTreasuryWallet; - // Payment token + // Payment token, SAND contract 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; } function _coreStorage() private pure returns (CoreStorage storage cs) { - bytes32 position = CORE_STORAGE_LOCATION; - // solhint-disable-next-line no-inline-assembly - assembly { - cs.slot := position - } - } - - struct UserStorage { - // Track nonces for replay protection - mapping(address => uint256) nonces; - } - - function _userStorage() private pure returns (UserStorage storage us) { - bytes32 position = USER_STORAGE_LOCATION; // solhint-disable-next-line no-inline-assembly assembly { - us.slot := position + cs.slot := CORE_STORAGE_LOCATION } } + /// @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) { - bytes32 position = TOKEN_STORAGE_LOCATION; // solhint-disable-next-line no-inline-assembly assembly { - ts.slot := position + ts.slot := TOKEN_STORAGE_LOCATION } } @@ -164,6 +136,14 @@ contract SandboxPasses1155Upgradeable 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 @@ -171,21 +151,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)"); @@ -197,34 +162,96 @@ contract SandboxPasses1155Upgradeable 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 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 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 maxMintable, + 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 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 maxMintable, + 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 // ============================================================= /// @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 burn and mint configuration doesn't exist - error BurnMintNotConfigured(uint256 burnTokenId); + /// @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 @@ -235,8 +262,10 @@ contract SandboxPasses1155Upgradeable is error InvalidSignature(ECDSA.RecoverError error); /// @dev Revert when invalid signer error InvalidSigner(); - /// @dev Revert when max supply below current supply - error MaxSupplyBelowCurrentSupply(uint256 tokenId); + /// @dev Revert when signature already used + error SignatureAlreadyUsed(uint256 signatureId); + /// @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 @@ -255,6 +284,10 @@ contract SandboxPasses1155Upgradeable 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(); + /// @dev Revert when treasury wallet is the same as the contract address + error InvalidTreasuryWallet(); // ============================================================= // Init @@ -267,58 +300,45 @@ contract SandboxPasses1155Upgradeable 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 - ) public 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(); - // Validate inputs - 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"); - // Check if _paymentToken is a contract - if (_paymentToken.code.length == 0) { + if (params.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); + _grantRole(DEFAULT_ADMIN_ROLE, params.admin); + _grantRole(ADMIN_ROLE, params.admin); + _grantRole(OPERATOR_ROLE, params.operator); + _grantRole(SIGNER_ROLE, params.signer); - // Initialize storage structs 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, @@ -329,7 +349,7 @@ contract SandboxPasses1155Upgradeable is ) ); - _setDefaultRoyalty(_royaltyReceiver, _royaltyFeeNumerator); + _setDefaultRoyalty(params.royaltyReceiver, params.royaltyFeeNumerator); } // ============================================================= @@ -344,13 +364,14 @@ contract SandboxPasses1155Upgradeable 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 */ @@ -360,15 +381,28 @@ contract SandboxPasses1155Upgradeable 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, ""); + CoreStorage storage cs = _coreStorage(); + if (_msgSender() != caller && _msgSender() != cs.paymentToken) { + revert InvalidSender(); + } + + MintRequest memory request = MintRequest({ + caller: caller, + tokenId: tokenId, + amount: amount, + price: price, + deadline: deadline, + signatureId: signatureId + }); + _processSingleMint(request, signature); } /** * @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 @@ -379,11 +413,12 @@ contract SandboxPasses1155Upgradeable 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 * - 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 */ @@ -393,50 +428,22 @@ contract SandboxPasses1155Upgradeable 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(); + CoreStorage storage cs = _coreStorage(); + if (_msgSender() != caller && _msgSender() != cs.paymentToken) { + revert InvalidSender(); } - 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); } /** @@ -445,11 +452,11 @@ contract SandboxPasses1155Upgradeable 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]; @@ -458,7 +465,7 @@ contract SandboxPasses1155Upgradeable is revert TokenNotConfigured(tokenId); } - _checkMaxSupply(tokenId, amount); + _updateAndCheckTotalMinted(tokenId, amount); config.mintedPerWallet[to] += amount; _mint(to, tokenId, amount, ""); @@ -470,13 +477,13 @@ contract SandboxPasses1155Upgradeable 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, @@ -493,7 +500,7 @@ contract SandboxPasses1155Upgradeable 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, ""); @@ -505,7 +512,7 @@ contract SandboxPasses1155Upgradeable 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] @@ -513,7 +520,7 @@ contract SandboxPasses1155Upgradeable 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, @@ -531,7 +538,7 @@ contract SandboxPasses1155Upgradeable 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], ""); } @@ -552,7 +559,7 @@ contract SandboxPasses1155Upgradeable 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( @@ -564,17 +571,21 @@ contract SandboxPasses1155Upgradeable is uint256 mintAmount ) external whenNotPaused onlyRole(OPERATOR_ROLE) { TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintTokenId]; + TokenConfig storage burnConfig = _tokenStorage().tokenConfigs[burnTokenId]; if (!mintConfig.isConfigured) { revert TokenNotConfigured(mintTokenId); } - _checkMaxSupply(mintTokenId, mintAmount); + if (!burnConfig.isConfigured) { + revert TokenNotConfigured(burnTokenId); + } + + _updateAndCheckTotalMinted(mintTokenId, mintAmount); mintConfig.mintedPerWallet[mintTo] += mintAmount; - // Burn first + _burn(burnFrom, burnTokenId, burnAmount); - // Then mint _checkMaxPerWallet(mintTokenId, mintTo, mintAmount); _mint(mintTo, mintTokenId, mintAmount, ""); } @@ -596,7 +607,7 @@ contract SandboxPasses1155Upgradeable 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( @@ -615,23 +626,26 @@ contract SandboxPasses1155Upgradeable 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]]; if (!mintConfig.isConfigured) { revert TokenNotConfigured(mintTokenIds[i]); } - _checkMaxSupply(mintTokenIds[i], mintAmounts[i]); + if (!burnConfig.isConfigured) { + revert TokenNotConfigured(burnTokenIds[i]); + } + + _updateAndCheckTotalMinted(mintTokenIds[i], mintAmounts[i]); _checkMaxPerWallet(mintTokenIds[i], mintTo, mintAmounts[i]); mintConfig.mintedPerWallet[mintTo] += mintAmounts[i]; } - // Burn tokens first _burnBatch(burnFrom, burnTokenIds, burnAmounts); - // Then mint new tokens _mintBatch(mintTo, mintTokenIds, mintAmounts, ""); } @@ -648,11 +662,11 @@ contract SandboxPasses1155Upgradeable 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 - * - Max supply would be exceeded for mint token + * - Max mintable would be exceeded for mint token * - Burn operation fails (insufficient balance) */ function burnAndMint( @@ -662,14 +676,19 @@ contract SandboxPasses1155Upgradeable is uint256 mintId, uint256 mintAmount, uint256 deadline, - bytes memory signature + 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); + revert TokenNotConfigured(burnId); } - // Check if mint token is configured and respects max supply TokenConfig storage mintConfig = _tokenStorage().tokenConfigs[mintId]; if (!mintConfig.isConfigured) { revert TokenNotConfigured(mintId); @@ -682,17 +701,16 @@ contract SandboxPasses1155Upgradeable is mintId: mintId, mintAmount: mintAmount, deadline: deadline, - nonce: _userStorage().nonces[caller]++ + signatureId: signatureId }); verifyBurnAndMintSignature(request, signature); - _checkMaxSupply(mintId, mintAmount); + _updateAndCheckTotalMinted(mintId, mintAmount); mintConfig.mintedPerWallet[caller] += mintAmount; - // Burn first + _burn(caller, burnId, burnAmount); - // Then mint new token _checkMaxPerWallet(mintId, caller, mintAmount); _mint(caller, mintId, mintAmount, ""); } @@ -759,8 +777,8 @@ contract SandboxPasses1155Upgradeable 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 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) * @dev Only callable by addresses with ADMIN_ROLE @@ -773,9 +791,9 @@ contract SandboxPasses1155Upgradeable is function configureToken( uint256 tokenId, bool transferable, - uint256 maxSupply, + uint256 maxMintable, uint256 maxPerWallet, - string memory metadata, + string calldata metadata, address treasuryWallet ) external onlyRole(ADMIN_ROLE) { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; @@ -784,36 +802,40 @@ contract SandboxPasses1155Upgradeable is revert TokenAlreadyConfigured(tokenId); } + if (treasuryWallet == address(this)) { + revert InvalidTreasuryWallet(); + } + 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 open edition) - * @param maxPerWallet New maximum tokens per wallet (0 for unlimited) + * @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 memory metadata, + string calldata metadata, address treasuryWallet ) external onlyRole(ADMIN_ROLE) { TokenConfig storage config = _tokenStorage().tokenConfigs[tokenId]; @@ -822,20 +844,20 @@ contract SandboxPasses1155Upgradeable is revert TokenNotConfigured(tokenId); } - // Cannot decrease maxSupply below current supply - if (maxSupply > 0) { - 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); } /** @@ -847,7 +869,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; @@ -860,7 +882,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); } /** @@ -916,6 +940,47 @@ contract SandboxPasses1155Upgradeable 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 @@ -936,17 +1001,6 @@ contract SandboxPasses1155Upgradeable 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 @@ -964,12 +1018,12 @@ contract SandboxPasses1155Upgradeable 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]; } /** @@ -977,7 +1031,7 @@ contract SandboxPasses1155Upgradeable 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 @@ -985,24 +1039,12 @@ contract SandboxPasses1155Upgradeable 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, config.transferable, - config.maxSupply, + config.maxMintable, config.metadata, config.maxPerWallet, config.treasuryWallet, @@ -1021,68 +1063,131 @@ contract SandboxPasses1155Upgradeable 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 + * @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 pause() external onlyRole(ADMIN_ROLE) { - _pause(); + function owner() external view returns (address) { + return _coreStorage().internalOwner; } /** - * @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 + * @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 unpause() external onlyRole(ADMIN_ROLE) { - _unpause(); + function uri(uint256 tokenId) public view virtual override returns (string memory) { + return string(abi.encodePacked(_coreStorage().baseURI, tokenId.toString(), ".json")); } /** - * @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 + * @notice Check if a token exists (has been configured) + * @param tokenId The token ID to check + * @return bool True if the token has been configured, false otherwise */ - 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(); + function exists(uint256 tokenId) public view override returns (bool) { + return _tokenStorage().tokenConfigs[tokenId].isConfigured; + } + + /** + * @notice Checks if contract implements various interfaces + * @param interfaceId The interface identifier to check + * @dev Combines interface support checks from parent contracts + * @dev Supports ERC1155, ERC2981, AccessControl interfaces + * @return bool True if the contract implements the interface + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(AccessControlUpgradeable, ERC1155Upgradeable, ERC2981Upgradeable) returns (bool) { + return super.supportsInterface(interfaceId); + } + + // ============================================================= + // 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); + } + } + } } - SafeERC20.safeTransfer(IERC20(token), to, amount); - emit TokensRecovered(_msgSender(), token, to, amount); + super._update(from, to, ids, values); } /** - * @notice Check if a token exists (has been configured) - * @param tokenId The token ID to check - * @return bool True if the token has been configured, false otherwise + * @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 exists(uint256 tokenId) public view override returns (bool) { - return _tokenStorage().tokenConfigs[tokenId].isConfigured; + 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(); } /** * @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 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) public view { + function verifySignature(MintRequest memory request, bytes memory signature) internal { bytes32 structHash = keccak256( abi.encode( MINT_TYPEHASH, @@ -1091,25 +1196,25 @@ contract SandboxPasses1155Upgradeable is request.amount, request.price, request.deadline, - request.nonce + request.signatureId ) ); - _verifySignature(structHash, signature, request.deadline); + _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 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) public view { + function verifyBurnAndMintSignature(BurnAndMintRequest memory request, bytes memory signature) internal { bytes32 structHash = keccak256( abi.encode( BURN_AND_MINT_TYPEHASH, @@ -1119,25 +1224,25 @@ contract SandboxPasses1155Upgradeable is request.mintId, request.mintAmount, request.deadline, - request.nonce + request.signatureId ) ); - _verifySignature(structHash, signature, request.deadline); + _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 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) public view { + function verifyBatchSignature(BatchMintRequest memory request, bytes memory signature) internal { bytes32 structHash = keccak256( abi.encode( BATCH_MINT_TYPEHASH, @@ -1146,84 +1251,81 @@ contract SandboxPasses1155Upgradeable 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); } - /** - * @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 - * @dev Combines interface support checks from parent contracts - * @dev Supports ERC1155, ERC2981, AccessControl interfaces - * @return bool True if the contract implements the interface - */ - function supportsInterface( - bytes4 interfaceId - ) public view virtual override(AccessControlUpgradeable, ERC1155Upgradeable, ERC2981Upgradeable) returns (bool) { - return super.supportsInterface(interfaceId); - } - - // ============================================================= - // Private and Internal Functions - // ============================================================= - /** * @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); + _updateAndCheckTotalMinted(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); + + 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]); + _updateAndCheckTotalMinted(request.tokenIds[i], request.amounts[i]); + + 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] + ); + } + + _mintBatch(request.caller, request.tokenIds, request.amounts, ""); } /** @@ -1237,11 +1339,17 @@ contract SandboxPasses1155Upgradeable 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); @@ -1254,22 +1362,21 @@ contract SandboxPasses1155Upgradeable 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 a max supply (> 0) and - * - 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]; - // 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); - } + + if (config.totalMinted > config.maxMintable) { + revert MaxMintableExceeded(tokenId); } } @@ -1280,84 +1387,19 @@ contract SandboxPasses1155Upgradeable 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) { - 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); - } - } - } + if (config.maxPerWallet == 0) { + revert ExceedsMaxPerWallet(tokenId, to, amount, 0); } - 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(); + 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 aed23f74ef..929ce1a658 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 { @@ -57,11 +57,11 @@ describe('SandboxPasses1155Upgradeable', 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('SandboxPasses1155Upgradeable', 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 () { @@ -139,7 +140,7 @@ describe('SandboxPasses1155Upgradeable', 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, @@ -156,13 +157,13 @@ describe('SandboxPasses1155Upgradeable', 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 () { + 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); @@ -171,7 +172,7 @@ describe('SandboxPasses1155Upgradeable', 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) @@ -184,7 +185,7 @@ describe('SandboxPasses1155Upgradeable', function () { ), ).to.be.revertedWithCustomError( sandboxPasses, - 'MaxSupplyBelowCurrentSupply', + 'MaxMintableBelowCurrentMinted', ); }); @@ -200,7 +201,7 @@ describe('SandboxPasses1155Upgradeable', 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('SandboxPasses1155Upgradeable', 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 () { @@ -286,6 +287,41 @@ describe('SandboxPasses1155Upgradeable', 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); @@ -293,7 +329,7 @@ describe('SandboxPasses1155Upgradeable', 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, @@ -418,7 +454,7 @@ describe('SandboxPasses1155Upgradeable', 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); @@ -426,7 +462,7 @@ describe('SandboxPasses1155Upgradeable', 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 () { @@ -479,26 +515,26 @@ describe('SandboxPasses1155Upgradeable', 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); @@ -508,7 +544,7 @@ describe('SandboxPasses1155Upgradeable', 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) @@ -517,7 +553,7 @@ describe('SandboxPasses1155Upgradeable', function () { [TOKEN_ID_1, TOKEN_ID_1], [6, 5], ), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); }); @@ -535,7 +571,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -550,7 +586,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Mint with signature @@ -563,6 +599,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ); expect(await sandboxPasses.balanceOf(user1.address, TOKEN_ID_1)).to.equal( @@ -588,7 +625,7 @@ describe('SandboxPasses1155Upgradeable', 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, @@ -597,7 +634,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); await expect( @@ -608,6 +645,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ]), ).to.not.be.reverted; }); @@ -628,7 +666,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -636,7 +674,6 @@ describe('SandboxPasses1155Upgradeable', function () { .approve(await sandboxPasses.getAddress(), price1 + price2); // Create signatures - const signature = await createBatchMintSignature( signer, user1.address, @@ -644,7 +681,7 @@ describe('SandboxPasses1155Upgradeable', function () { [MINT_AMOUNT, MINT_AMOUNT * 2], [price1, price2], deadline, - nonce1, + signatureId, ); await expect( @@ -655,6 +692,7 @@ describe('SandboxPasses1155Upgradeable', function () { [price1, price2], deadline, signature, + signatureId, ]), ).to.not.be.reverted; }); @@ -675,7 +713,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -690,7 +728,7 @@ describe('SandboxPasses1155Upgradeable', function () { [MINT_AMOUNT, MINT_AMOUNT * 2], [price1, price2], deadline, - nonce1, + signatureId, ); // Batch mint with signatures @@ -703,6 +741,7 @@ describe('SandboxPasses1155Upgradeable', function () { [price1, price2], deadline, signature, + signatureId, ); expect(await sandboxPasses.balanceOf(user1.address, TOKEN_ID_1)).to.equal( @@ -729,7 +768,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -739,7 +778,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint with expired signature @@ -753,6 +792,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'SignatureExpired'); }); @@ -768,7 +808,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -778,7 +818,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint with invalid signature @@ -792,6 +832,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -808,7 +849,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -823,7 +864,7 @@ describe('SandboxPasses1155Upgradeable', function () { MAX_PER_WALLET + 1, price, deadline, - nonce, + signatureId, ); // Try to mint more than max per wallet @@ -837,6 +878,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'ExceedsMaxPerWallet'); }); @@ -854,12 +896,12 @@ describe('SandboxPasses1155Upgradeable', 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++) { @@ -867,7 +909,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -885,7 +927,7 @@ describe('SandboxPasses1155Upgradeable', function () { amounts, prices, deadline, - nonce, + signatureId, ); // Approve payment token for the whole batch @@ -906,6 +948,7 @@ describe('SandboxPasses1155Upgradeable', function () { prices, deadline, signature, + signatureId, ); // Verify a few tokens were minted successfully @@ -930,12 +973,12 @@ describe('SandboxPasses1155Upgradeable', 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++) { @@ -943,7 +986,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -962,7 +1005,7 @@ describe('SandboxPasses1155Upgradeable', function () { amounts, prices, deadline, - nonce, + signatureId, ); // Approve payment token for the whole batch @@ -984,160 +1027,85 @@ describe('SandboxPasses1155Upgradeable', 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 () { @@ -1153,7 +1121,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -1168,7 +1136,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // User1 attempts to use user2's signature @@ -1182,6 +1150,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1199,7 +1168,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -1214,7 +1183,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint TOKEN_ID_2 instead @@ -1226,6 +1195,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1242,7 +1212,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -1257,7 +1227,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint different amount @@ -1269,6 +1239,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1286,7 +1257,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -1301,7 +1272,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint with different price @@ -1313,6 +1284,7 @@ describe('SandboxPasses1155Upgradeable', function () { incorrectPrice, // Different price deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1332,7 +1304,7 @@ describe('SandboxPasses1155Upgradeable', 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 @@ -1347,7 +1319,7 @@ describe('SandboxPasses1155Upgradeable', function () { [MINT_AMOUNT, MINT_AMOUNT * 2], [price1, price2], deadline, - nonce, + signatureId, ); // INVALID TOKEN_ID of the second token @@ -1361,6 +1333,7 @@ describe('SandboxPasses1155Upgradeable', function () { [price1, price2], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); @@ -1375,6 +1348,7 @@ describe('SandboxPasses1155Upgradeable', function () { [price1, price2], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); @@ -1390,6 +1364,7 @@ describe('SandboxPasses1155Upgradeable', function () { [incorrectPrice, price2], deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); @@ -1405,11 +1380,12 @@ describe('SandboxPasses1155Upgradeable', function () { [price1, price2], incorrectDeadline, signature, + signatureId, ), ).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, @@ -1427,7 +1403,7 @@ describe('SandboxPasses1155Upgradeable', function () { const price = ethers.parseEther('0.1'); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; // Approve payment token await paymentToken @@ -1442,11 +1418,11 @@ describe('SandboxPasses1155Upgradeable', function () { [3, 3], [price, price], deadline, - nonce, + signatureId, ); // 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) @@ -1457,11 +1433,12 @@ describe('SandboxPasses1155Upgradeable', function () { [price, price], deadline, signature, + signatureId, ), - ).to.be.revertedWithCustomError(sandboxPasses, 'MaxSupplyExceeded'); + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); }); - 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, @@ -1471,15 +1448,15 @@ describe('SandboxPasses1155Upgradeable', 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 + const LARGE_MAX_SUPPLY = 1000; // Just to make sure we don't hit max mintable await sandboxPasses.connect(admin).configureToken( UNLIMITED_TOKEN_ID, true, // transferable - LARGE_MAX_SUPPLY, // max supply - 0, // maxPerWallet = 0 (unlimited) + LARGE_MAX_SUPPLY, // max mintable + ethers.MaxUint256, // maxPerWallet = type(uint256).max (unlimited) 'ipfs://unlimited-token', // metadata ethers.ZeroAddress, // use default treasury ); @@ -1489,7 +1466,7 @@ describe('SandboxPasses1155Upgradeable', function () { // First mint const mintAmount1 = 10; - const nonce1 = 0; + const signatureId1 = 12345; // Approve payment token for first mint await paymentToken @@ -1503,7 +1480,7 @@ describe('SandboxPasses1155Upgradeable', function () { mintAmount1, price, deadline, - nonce1, + signatureId1, ); // First mint should succeed @@ -1516,6 +1493,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature1, + signatureId1, ); // Check balance after first mint @@ -1525,7 +1503,7 @@ describe('SandboxPasses1155Upgradeable', function () { // Second mint with a larger amount const mintAmount2 = 20; - const nonce2 = 1; + const signatureId2 = 123456; // Approve payment token for second mint await paymentToken @@ -1539,7 +1517,7 @@ describe('SandboxPasses1155Upgradeable', function () { mintAmount2, price, deadline, - nonce2, + signatureId2, ); // Second mint should also succeed despite exceeding what would normally be max per wallet @@ -1552,6 +1530,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature2, + signatureId2, ); // Check total balance after both mints @@ -1566,6 +1545,184 @@ describe('SandboxPasses1155Upgradeable', 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 mintable + 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 maxMintable is 0 (disabled)', async function () { + const { + sandboxPasses, + signer, + user1, + paymentToken, + admin, + createMintSignature, + } = await loadFixture(runCreateTestSetup); + + // Configure a new token with maxMintable = 0 (disabled) + const DISABLED_TOKEN_ID = 7777; + + await sandboxPasses.connect(admin).configureToken( + DISABLED_TOKEN_ID, + true, // transferable + 0, // maxMintable = 0 (disabled) + 10, // maxPerWallet = 10 + 'ipfs://disabled-mintable-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 maxMintable is 0 (disabled) + await expect( + sandboxPasses + .connect(user1) + .mint( + user1.address, + DISABLED_TOKEN_ID, + mintAmount, + price, + deadline, + signature, + signatureId, + ), + ).to.be.revertedWithCustomError(sandboxPasses, 'MaxMintableExceeded'); + }); + + it('should allow unlimited mints when maxMintable is type(uint256).max', async function () { + const { + sandboxPasses, + signer, + user1, + paymentToken, + admin, + createMintSignature, + } = await loadFixture(runCreateTestSetup); + + // 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, // maxMintable = type(uint256).max (unlimited) + NORMAL_MAX_PER_WALLET, // maxPerWallet + 'ipfs://unlimited-mintable-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 mintable + 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 () { @@ -1763,7 +1920,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -1774,7 +1931,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Burn and mint with signature @@ -1788,6 +1945,7 @@ describe('SandboxPasses1155Upgradeable', function () { 3, deadline, signature, + signatureId, ); expect(await sandboxPasses.balanceOf(user1.address, TOKEN_ID_1)).to.equal( @@ -1803,7 +1961,7 @@ describe('SandboxPasses1155Upgradeable', 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); @@ -1818,7 +1976,7 @@ describe('SandboxPasses1155Upgradeable', 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) @@ -1830,7 +1988,7 @@ describe('SandboxPasses1155Upgradeable', 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 () { @@ -1884,7 +2042,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -1895,7 +2053,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn and mint with expired signature @@ -1910,6 +2068,7 @@ describe('SandboxPasses1155Upgradeable', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'SignatureExpired'); }); @@ -1932,7 +2091,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -1943,7 +2102,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn and mint @@ -1958,6 +2117,7 @@ describe('SandboxPasses1155Upgradeable', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -1970,6 +2130,7 @@ describe('SandboxPasses1155Upgradeable', function () { admin, TOKEN_ID_1, TOKEN_ID_2, + TOKEN_ID_3, MINT_AMOUNT, createBurnAndMintSignature, } = await loadFixture(runCreateTestSetup); @@ -1980,7 +2141,17 @@ describe('SandboxPasses1155Upgradeable', function () { .adminMint(user1.address, TOKEN_ID_1, MINT_AMOUNT); const deadline = (await time.latest()) + 3600; - const nonce = 0; + const signatureId = 12345; + + // configure TOKEN_ID_3 + await sandboxPasses.connect(admin).configureToken( + TOKEN_ID_3, + true, // transferable + 100, // max mintable + 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( @@ -1991,19 +2162,20 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn TOKEN_ID_2 instead (which user doesn't have) 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, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2026,7 +2198,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2037,7 +2209,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to burn 3 tokens instead @@ -2050,6 +2222,7 @@ describe('SandboxPasses1155Upgradeable', function () { 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2062,6 +2235,7 @@ describe('SandboxPasses1155Upgradeable', function () { admin, TOKEN_ID_1, TOKEN_ID_2, + TOKEN_ID_3, MINT_AMOUNT, createBurnAndMintSignature, } = await loadFixture(runCreateTestSetup); @@ -2072,7 +2246,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2083,7 +2257,17 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, + ); + + // configure TOKEN_ID_3 + await sandboxPasses.connect(admin).configureToken( + TOKEN_ID_3, + true, // transferable + 100, // max mintable + 10, // max per wallet + `ipfs://token${TOKEN_ID_3}`, // metadata + ethers.ZeroAddress, // use default treasury ); // Try to mint TOKEN_ID_1 instead @@ -2092,10 +2276,11 @@ describe('SandboxPasses1155Upgradeable', function () { user1.address, TOKEN_ID_1, 2, - TOKEN_ID_1, // Different mint token ID + TOKEN_ID_3, // Different mint token ID 3, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2118,7 +2303,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2129,7 +2314,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Try to mint 4 tokens instead @@ -2142,6 +2327,7 @@ describe('SandboxPasses1155Upgradeable', function () { 4, // Different mint amount deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'InvalidSigner'); }); @@ -2164,7 +2350,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2175,7 +2361,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // First burn and mint should succeed @@ -2189,6 +2375,7 @@ describe('SandboxPasses1155Upgradeable', function () { 3, deadline, signature, + signatureId, ); // Second attempt with same signature should fail (replay attack) @@ -2203,11 +2390,12 @@ describe('SandboxPasses1155Upgradeable', 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, @@ -2224,11 +2412,8 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2239,7 +2424,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); // Burn and mint @@ -2253,10 +2438,13 @@ describe('SandboxPasses1155Upgradeable', 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, + ); }); }); @@ -2532,7 +2720,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2542,7 +2730,7 @@ describe('SandboxPasses1155Upgradeable', function () { MINT_AMOUNT, price, deadline, - nonce, + signatureId, ); // Try to mint while paused @@ -2556,6 +2744,7 @@ describe('SandboxPasses1155Upgradeable', function () { price, deadline, signature, + signatureId, ), ).to.be.revertedWithCustomError(sandboxPasses, 'EnforcedPause'); }); @@ -2684,7 +2873,7 @@ describe('SandboxPasses1155Upgradeable', 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, @@ -2852,7 +3041,7 @@ describe('SandboxPasses1155Upgradeable', 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, @@ -2862,7 +3051,7 @@ describe('SandboxPasses1155Upgradeable', 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( @@ -2873,7 +3062,7 @@ describe('SandboxPasses1155Upgradeable', function () { TOKEN_ID_2, 3, deadline, - nonce, + signatureId, ); await expect( @@ -2887,8 +3076,9 @@ describe('SandboxPasses1155Upgradeable', function () { 3, deadline, signature, + signatureId, ), - ).to.be.revertedWithCustomError(sandboxPasses, 'BurnMintNotConfigured'); + ).to.be.revertedWithCustomError(sandboxPasses, 'TokenNotConfigured'); }); it('should revert with ArrayLengthMismatch in batch operations', async function () { @@ -2923,16 +3113,18 @@ describe('SandboxPasses1155Upgradeable', 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(), @@ -2942,16 +3134,18 @@ describe('SandboxPasses1155Upgradeable', 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'); }); @@ -2973,16 +3167,18 @@ describe('SandboxPasses1155Upgradeable', 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 ae4c1118ab..044a51aba1 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'; @@ -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 @@ -195,21 +195,21 @@ 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, - ROYALTY_PERCENTAGE, - admin.address, - operator.address, - signer.address, - await paymentToken.getAddress(), - trustedForwarder.address, - treasury.address, - owner.address, - ])) as unknown as SandboxPasses1155Upgradeable; + { + 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(); // Set up default token configuration @@ -251,6 +251,7 @@ export async function runCreateTestSetup() { BigNumberish, BigNumberish, BytesLike, + BigNumberish, ], ) => { const encodedData = sandboxPasses.interface.encodeFunctionData( @@ -275,6 +276,7 @@ export async function runCreateTestSetup() { BigNumberish[], BigNumberish, BytesLike, + BigNumberish, ], ) => { const encodedData = sandboxPasses.interface.encodeFunctionData(