diff --git a/contracts/ForwarderEOAOnly.sol b/contracts/ForwarderEOAOnly.sol new file mode 100644 index 000000000..6a4b26f1d --- /dev/null +++ b/contracts/ForwarderEOAOnly.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "./openzeppelin-presets/metatx/MinimalForwarderEOAOnly.sol"; + +/* + * @dev Minimal forwarder for GSNv2 + */ +contract ForwarderEOAOnly is MinimalForwarderEOAOnly { + // solhint-disable-next-line no-empty-blocks + constructor() MinimalForwarderEOAOnly() {} +} diff --git a/contracts/extension/TokenBundle.sol b/contracts/extension/TokenBundle.sol index 66f0f04ea..ba91fc847 100644 --- a/contracts/extension/TokenBundle.sol +++ b/contracts/extension/TokenBundle.sol @@ -17,7 +17,7 @@ interface IERC165 { abstract contract TokenBundle is ITokenBundle { /// @dev Mapping from bundle UID => bundle info. - mapping(uint256 => BundleInfo) private bundle; + mapping(uint256 => BundleInfo) public bundle; /// @dev Returns the total number of assets in a particular bundle. function getTokenCountOfBundle(uint256 _bundleId) public view returns (uint256) { @@ -38,8 +38,8 @@ abstract contract TokenBundle is ITokenBundle { function _createBundle(Token[] calldata _tokensToBind, uint256 _bundleId) internal { uint256 targetCount = _tokensToBind.length; - require(targetCount > 0, "TokenBundle: no tokens to bind."); - require(bundle[_bundleId].count == 0, "TokenBundle: existent at bundleId"); + require(targetCount > 0, "no tokens to bind"); + require(bundle[_bundleId].count == 0, "existent at bundleId"); for (uint256 i = 0; i < targetCount; i += 1) { _checkTokenType(_tokensToBind[i]); @@ -50,8 +50,8 @@ abstract contract TokenBundle is ITokenBundle { } /// @dev Lets the calling contract update a bundle, by passing in a list of tokens and a unique id. - function _updateBundle(Token[] calldata _tokensToBind, uint256 _bundleId) internal { - require(_tokensToBind.length > 0, "TokenBundle: no tokens to bind."); + function _updateBundle(Token[] memory _tokensToBind, uint256 _bundleId) internal { + require(_tokensToBind.length > 0, "no tokens to bind"); uint256 currentCount = bundle[_bundleId].count; uint256 targetCount = _tokensToBind.length; @@ -84,7 +84,7 @@ abstract contract TokenBundle is ITokenBundle { uint256 _bundleId, uint256 _index ) internal { - require(_index < bundle[_bundleId].count, "TokenBundle: index DNE."); + require(_index < bundle[_bundleId].count, "index DNE"); _checkTokenType(_tokenToBind); bundle[_bundleId].tokens[_index] = _tokenToBind; } @@ -93,24 +93,24 @@ abstract contract TokenBundle is ITokenBundle { function _checkTokenType(Token memory _token) internal view { if (_token.tokenType == TokenType.ERC721) { try IERC165(_token.assetContract).supportsInterface(0x80ac58cd) returns (bool supported721) { - require(supported721, "Asset doesn't match TokenType"); + require(supported721, "!TokenType"); } catch { - revert("Asset doesn't match TokenType"); + revert("!TokenType"); } } else if (_token.tokenType == TokenType.ERC1155) { try IERC165(_token.assetContract).supportsInterface(0xd9b67a26) returns (bool supported1155) { - require(supported1155, "Asset doesn't match TokenType"); + require(supported1155, "!TokenType"); } catch { - revert("Asset doesn't match TokenType"); + revert("!TokenType"); } } else if (_token.tokenType == TokenType.ERC20) { if (_token.assetContract != CurrencyTransferLib.NATIVE_TOKEN) { // 0x36372b07 try IERC165(_token.assetContract).supportsInterface(0x80ac58cd) returns (bool supported721) { - require(!supported721, "Asset doesn't match TokenType"); + require(!supported721, "!TokenType"); try IERC165(_token.assetContract).supportsInterface(0xd9b67a26) returns (bool supported1155) { - require(!supported1155, "Asset doesn't match TokenType"); + require(!supported1155, "!TokenType"); } catch Error(string memory) {} catch {} } catch Error(string memory) {} catch {} } @@ -118,7 +118,7 @@ abstract contract TokenBundle is ITokenBundle { } /// @dev Lets the calling contract set/update the uri of a particular bundle. - function _setUriOfBundle(string calldata _uri, uint256 _bundleId) internal { + function _setUriOfBundle(string memory _uri, uint256 _bundleId) internal { bundle[_bundleId].uri = _uri; } diff --git a/contracts/extension/TokenStore.sol b/contracts/extension/TokenStore.sol index e2cd06650..95233ae60 100644 --- a/contracts/extension/TokenStore.sol +++ b/contracts/extension/TokenStore.sol @@ -22,9 +22,6 @@ import "../lib/CurrencyTransferLib.sol"; */ contract TokenStore is TokenBundle, ERC721Holder, ERC1155Holder { - /// @dev The address interpreted as native token of the chain. - address public constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - /// @dev The address of the native token wrapper contract. address internal immutable nativeTokenWrapper; @@ -36,7 +33,7 @@ contract TokenStore is TokenBundle, ERC721Holder, ERC1155Holder { function _storeTokens( address _tokenOwner, Token[] calldata _tokens, - string calldata _uriForTokens, + string memory _uriForTokens, uint256 _idForTokens ) internal { _createBundle(_tokens, _idForTokens); diff --git a/contracts/interfaces/IPack.sol b/contracts/interfaces/IPack.sol index 0035d3619..d60a4f554 100644 --- a/contracts/interfaces/IPack.sol +++ b/contracts/interfaces/IPack.sol @@ -24,12 +24,10 @@ interface IPack is ITokenBundle { } /// @notice Emitted when a set of packs is created. - event PackCreated( - uint256 indexed packId, - address indexed packCreator, - address recipient, - uint256 totalPacksCreated - ); + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when more packs are minted for a packId. + event PackUpdated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); /// @notice Emitted when a pack is opened. event PackOpened( diff --git a/contracts/openzeppelin-presets/metatx/MinimalForwarderEOAOnly.sol b/contracts/openzeppelin-presets/metatx/MinimalForwarderEOAOnly.sol new file mode 100644 index 000000000..abf340cd1 --- /dev/null +++ b/contracts/openzeppelin-presets/metatx/MinimalForwarderEOAOnly.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (metatx/MinimalForwarder.sol) + +pragma solidity ^0.8.0; + +import "../utils/cryptography/ECDSA.sol"; +import "../utils/cryptography/EIP712.sol"; + +/** + * @dev Simple minimal forwarder to be used together with an ERC2771 compatible contract. See {ERC2771Context}. + */ +contract MinimalForwarderEOAOnly is EIP712 { + using ECDSA for bytes32; + + struct ForwardRequest { + address from; + address to; + uint256 value; + uint256 gas; + uint256 nonce; + bytes data; + } + + bytes32 private constant _TYPEHASH = + keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"); + + mapping(address => uint256) private _nonces; + + constructor() EIP712("MinimalForwarderEOAOnly", "0.0.1") {} + + function getNonce(address from) public view returns (uint256) { + return _nonces[from]; + } + + function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { + address signer = _hashTypedDataV4( + keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data))) + ).recover(signature); + return _nonces[req.from] == req.nonce && signer == req.from; + } + + function execute(ForwardRequest calldata req, bytes calldata signature) + public + payable + returns (bool, bytes memory) + { + require(msg.sender == tx.origin, "not EOA"); + require(verify(req, signature), "MinimalForwarder: signature does not match request"); + _nonces[req.from] = req.nonce + 1; + + (bool success, bytes memory returndata) = req.to.call{ gas: req.gas, value: req.value }( + abi.encodePacked(req.data, req.from) + ); + + // Validate that the relayer has sent enough gas for the call. + // See https://ronan.eth.link/blog/ethereum-gas-dangers/ + if (gasleft() <= req.gas / 63) { + // We explicitly trigger invalid opcode to consume all gas and bubble-up the effects, since + // neither revert or assert consume all gas since Solidity 0.8.0 + // https://docs.soliditylang.org/en/v0.8.0/control-structures.html#panic-via-assert-and-error-via-require + assembly { + invalid() + } + } + + return (success, returndata); + } +} diff --git a/contracts/pack/Pack.sol b/contracts/pack/Pack.sol index e038bb271..ad58cd400 100644 --- a/contracts/pack/Pack.sol +++ b/contracts/pack/Pack.sol @@ -9,6 +9,8 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; +import "@openzeppelin/contracts/interfaces/IERC721Receiver.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/interfaces/IERC1155Receiver.sol"; // ========== Internal imports ========== @@ -20,7 +22,7 @@ import "../openzeppelin-presets/metatx/ERC2771ContextUpgradeable.sol"; import "../extension/ContractMetadata.sol"; import "../extension/Royalty.sol"; import "../extension/Ownable.sol"; -import "../extension/PermissionsEnumerable.sol"; +import "../extension/Permissions.sol"; import { TokenStore, ERC1155Receiver } from "../extension/TokenStore.sol"; contract Pack is @@ -28,12 +30,12 @@ contract Pack is ContractMetadata, Ownable, Royalty, - PermissionsEnumerable, + Permissions, TokenStore, ReentrancyGuardUpgradeable, ERC2771ContextUpgradeable, MulticallUpgradeable, - ERC1155PausableUpgradeable, + ERC1155Upgradeable, IPack { /*/////////////////////////////////////////////////////////////// @@ -43,6 +45,8 @@ contract Pack is bytes32 private constant MODULE_TYPE = bytes32("Pack"); uint256 private constant VERSION = 1; + address private immutable forwarder; + // Token name string public name; @@ -50,11 +54,13 @@ contract Pack is string public symbol; /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. - bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can create packs. - bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 private minterRole; + /// @dev Only assets with ASSET_ROLE can be packed, when packing is restricted to particular assets. - bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + bytes32 private assetRole; /// @dev The token Id of the next set of packs to be minted. uint256 public nextTokenIdToMint; @@ -69,11 +75,16 @@ contract Pack is /// @dev Mapping from pack ID => The state of that set of packs. mapping(uint256 => PackInfo) private packInfo; + /// @dev Checks if pack-creator allowed to add more tokens to a packId; set to false after first transfer + mapping(uint256 => bool) public canUpdatePack; + /*/////////////////////////////////////////////////////////////// Constructor + initializer logic //////////////////////////////////////////////////////////////*/ - constructor(address _nativeTokenWrapper) TokenStore(_nativeTokenWrapper) initializer {} + constructor(address _nativeTokenWrapper, address _trustedForwarder) TokenStore(_nativeTokenWrapper) initializer { + forwarder = _trustedForwarder; + } /// @dev Initiliazes the contract, like a constructor. function initialize( @@ -81,14 +92,18 @@ contract Pack is string memory _name, string memory _symbol, string memory _contractURI, - address[] memory _trustedForwarders, address _royaltyRecipient, uint256 _royaltyBps ) external initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + bytes32 _assetRole = keccak256("ASSET_ROLE"); // Initialize inherited contracts, most base-like -> most derived. __ReentrancyGuard_init(); - __ERC2771Context_init(_trustedForwarders); - __ERC1155Pausable_init(); + + address[] memory forwarders = new address[](1); + forwarders[0] = forwarder; + __ERC2771Context_init(forwarders); __ERC1155_init(_contractURI); name = _name; @@ -98,18 +113,23 @@ contract Pack is _setupOwner(_defaultAdmin); _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); - _setupRole(TRANSFER_ROLE, _defaultAdmin); - _setupRole(MINTER_ROLE, _defaultAdmin); - _setupRole(TRANSFER_ROLE, address(0)); + + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); // note: see `onlyRoleWithSwitch` for ASSET_ROLE behaviour. - _setupRole(ASSET_ROLE, address(0)); + _setupRole(_assetRole, address(0)); _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + + transferRole = _transferRole; + minterRole = _minterRole; + assetRole = _assetRole; } receive() external payable { - require(msg.sender == nativeTokenWrapper, "Caller is not native token wrapper."); + require(msg.sender == nativeTokenWrapper, "!nativeTokenWrapper."); } /*/////////////////////////////////////////////////////////////// @@ -135,15 +155,6 @@ contract Pack is return uint8(VERSION); } - /// @dev Pauses / unpauses contract. - function pause(bool _toPause) internal onlyRole(DEFAULT_ADMIN_ROLE) { - if (_toPause) { - _pause(); - } else { - _unpause(); - } - } - /*/////////////////////////////////////////////////////////////// ERC 165 / 1155 / 2981 logic //////////////////////////////////////////////////////////////*/ @@ -161,7 +172,11 @@ contract Pack is override(ERC1155Receiver, ERC1155Upgradeable, IERC165) returns (bool) { - return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + return + super.supportsInterface(interfaceId) || + type(IERC2981Upgradeable).interfaceId == interfaceId || + type(IERC721Receiver).interfaceId == interfaceId || + type(IERC1155Receiver).interfaceId == interfaceId; } /*/////////////////////////////////////////////////////////////// @@ -172,62 +187,92 @@ contract Pack is function createPack( Token[] calldata _contents, uint256[] calldata _numOfRewardUnits, - string calldata _packUri, + string memory _packUri, uint128 _openStartTimestamp, uint128 _amountDistributedPerOpen, address _recipient - ) - external - payable - onlyRoleWithSwitch(MINTER_ROLE) - nonReentrant - whenNotPaused - returns (uint256 packId, uint256 packTotalSupply) - { - require(_contents.length > 0, "nothing to pack"); - require(_contents.length == _numOfRewardUnits.length, "invalid reward units"); + ) external payable onlyRoleWithSwitch(minterRole) nonReentrant returns (uint256 packId, uint256 packTotalSupply) { + require(_contents.length > 0, "!Contents"); + require(_contents.length == _numOfRewardUnits.length, "!Rewards"); - if (!hasRole(ASSET_ROLE, address(0))) { + if (!hasRole(assetRole, address(0))) { for (uint256 i = 0; i < _contents.length; i += 1) { - _checkRole(ASSET_ROLE, _contents[i].assetContract); + _checkRole(assetRole, _contents[i].assetContract); } } packId = nextTokenIdToMint; nextTokenIdToMint += 1; - packTotalSupply = escrowPackContents(_contents, _numOfRewardUnits, _packUri, packId, _amountDistributedPerOpen); + packTotalSupply = escrowPackContents( + _contents, + _numOfRewardUnits, + _packUri, + packId, + _amountDistributedPerOpen, + false + ); packInfo[packId].openStartTimestamp = _openStartTimestamp; packInfo[packId].amountDistributedPerOpen = _amountDistributedPerOpen; + canUpdatePack[packId] = true; + _mint(_recipient, packId, packTotalSupply, ""); - emit PackCreated(packId, _msgSender(), _recipient, packTotalSupply); + emit PackCreated(packId, _recipient, packTotalSupply); } - /// @notice Lets a pack owner open packs and receive the packs' reward units. - function openPack(uint256 _packId, uint256 _amountToOpen) + function addPackContents( + uint256 _packId, + Token[] calldata _contents, + uint256[] calldata _numOfRewardUnits, + address _recipient + ) external + payable + onlyRoleWithSwitch(minterRole) nonReentrant - whenNotPaused - returns (Token[] memory) + returns (uint256 packTotalSupply, uint256 newSupplyAdded) { + require(canUpdatePack[_packId], "!Allowed"); + require(_contents.length > 0, "!Contents"); + require(_contents.length == _numOfRewardUnits.length, "!Rewards"); + require(balanceOf(_recipient, _packId) != 0, "!Recipient"); + + if (!hasRole(assetRole, address(0))) { + for (uint256 i = 0; i < _contents.length; i += 1) { + _checkRole(assetRole, _contents[i].assetContract); + } + } + + uint256 amountPerOpen = packInfo[_packId].amountDistributedPerOpen; + + newSupplyAdded = escrowPackContents(_contents, _numOfRewardUnits, "", _packId, amountPerOpen, true); + packTotalSupply = totalSupply[_packId] + newSupplyAdded; + + _mint(_recipient, _packId, newSupplyAdded, ""); + + emit PackUpdated(_packId, _recipient, newSupplyAdded); + } + + /// @notice Lets a pack owner open packs and receive the packs' reward units. + function openPack(uint256 _packId, uint256 _amountToOpen) external nonReentrant returns (Token[] memory) { address opener = _msgSender(); - require(isTrustedForwarder(opener) || opener == tx.origin, "opener must be eoa"); - require(balanceOf(opener, _packId) >= _amountToOpen, "opening more than owned"); + require(isTrustedForwarder(msg.sender) || opener == tx.origin, "!EOA"); + require(balanceOf(opener, _packId) >= _amountToOpen, "!Balance"); PackInfo memory pack = packInfo[_packId]; - require(pack.openStartTimestamp <= block.timestamp, "cannot open yet"); + require(pack.openStartTimestamp <= block.timestamp, "cant open"); Token[] memory rewardUnits = getRewardUnits(_packId, _amountToOpen, pack.amountDistributedPerOpen, pack); - _burn(_msgSender(), _packId, _amountToOpen); + _burn(opener, _packId, _amountToOpen); - _transferTokenBatch(address(this), _msgSender(), rewardUnits); + _transferTokenBatch(address(this), opener, rewardUnits); - emit PackOpened(_packId, _msgSender(), _amountToOpen, rewardUnits); + emit PackOpened(_packId, opener, _amountToOpen, rewardUnits); return rewardUnits; } @@ -236,29 +281,34 @@ contract Pack is function escrowPackContents( Token[] calldata _contents, uint256[] calldata _numOfRewardUnits, - string calldata _packUri, + string memory _packUri, uint256 packId, - uint256 amountPerOpen - ) internal returns (uint256 packTotalSupply) { - uint256 totalRewardUnits; + uint256 amountPerOpen, + bool isUpdate + ) internal returns (uint256 supplyToMint) { + uint256 sumOfRewardUnits; for (uint256 i = 0; i < _contents.length; i += 1) { - require(_contents[i].totalAmount != 0, "amount can't be zero"); - require(_contents[i].totalAmount % _numOfRewardUnits[i] == 0, "invalid reward units"); - require( - _contents[i].tokenType != TokenType.ERC721 || _contents[i].totalAmount == 1, - "invalid erc721 rewards" - ); + require(_contents[i].totalAmount != 0, "0 amt"); + require(_contents[i].totalAmount % _numOfRewardUnits[i] == 0, "!Rewards"); + require(_contents[i].tokenType != TokenType.ERC721 || _contents[i].totalAmount == 1, "!Rewards"); - totalRewardUnits += _numOfRewardUnits[i]; + sumOfRewardUnits += _numOfRewardUnits[i]; packInfo[packId].perUnitAmounts.push(_contents[i].totalAmount / _numOfRewardUnits[i]); } - require(totalRewardUnits % amountPerOpen == 0, "invalid amount to distribute per open"); - packTotalSupply = totalRewardUnits / amountPerOpen; + require(sumOfRewardUnits % amountPerOpen == 0, "!Amounts"); + supplyToMint = sumOfRewardUnits / amountPerOpen; - _storeTokens(_msgSender(), _contents, _packUri, packId); + if (isUpdate) { + for (uint256 i = 0; i < _contents.length; i += 1) { + _addTokenInBundle(_contents[i], packId); + } + _transferTokenBatch(_msgSender(), address(this), _contents); + } else { + _storeTokens(_msgSender(), _contents, _packUri, packId); + } } /// @dev Returns the reward units to distribute. @@ -270,27 +320,28 @@ contract Pack is ) internal returns (Token[] memory rewardUnits) { uint256 numOfRewardUnitsToDistribute = _numOfPacksToOpen * _rewardUnitsPerOpen; rewardUnits = new Token[](numOfRewardUnitsToDistribute); - uint256 totalRewardUnits = totalSupply[_packId] * _rewardUnitsPerOpen; uint256 totalRewardKinds = getTokenCountOfBundle(_packId); uint256 random = generateRandomValue(); + (Token[] memory _token, ) = getPackContents(_packId); + bool[] memory _isUpdated = new bool[](totalRewardKinds); for (uint256 i = 0; i < numOfRewardUnitsToDistribute; i += 1) { uint256 randomVal = uint256(keccak256(abi.encode(random, i))); uint256 target = randomVal % totalRewardUnits; uint256 step; for (uint256 j = 0; j < totalRewardKinds; j += 1) { - uint256 id = _packId; - - Token memory _token = getTokenOfBundle(id, j); - uint256 totalRewardUnitsOfKind = _token.totalAmount / pack.perUnitAmounts[j]; + uint256 totalRewardUnitsOfKind = _token[j].totalAmount / pack.perUnitAmounts[j]; if (target < step + totalRewardUnitsOfKind) { - _token.totalAmount -= pack.perUnitAmounts[j]; - _updateTokenInBundle(_token, id, j); - rewardUnits[i] = _token; + _token[j].totalAmount -= pack.perUnitAmounts[j]; + _isUpdated[j] = true; + + rewardUnits[i].assetContract = _token[j].assetContract; + rewardUnits[i].tokenType = _token[j].tokenType; + rewardUnits[i].tokenId = _token[j].tokenId; rewardUnits[i].totalAmount = pack.perUnitAmounts[j]; totalRewardUnits -= 1; @@ -301,6 +352,12 @@ contract Pack is } } } + + for (uint256 i = 0; i < totalRewardKinds; i += 1) { + if (_isUpdated[i]) { + _updateTokenInBundle(_token[i], _packId, i); + } + } } /*/////////////////////////////////////////////////////////////// @@ -309,7 +366,7 @@ contract Pack is /// @dev Returns the underlying contents of a pack. function getPackContents(uint256 _packId) - external + public view returns (Token[] memory contents, uint256[] memory perUnitAmounts) { @@ -320,8 +377,8 @@ contract Pack is for (uint256 i = 0; i < total; i += 1) { contents[i] = getTokenOfBundle(_packId, i); - perUnitAmounts[i] = pack.perUnitAmounts[i]; } + perUnitAmounts = pack.perUnitAmounts; } /*/////////////////////////////////////////////////////////////// @@ -365,14 +422,21 @@ contract Pack is super._beforeTokenTransfer(operator, from, to, ids, amounts, data); // if transfer is restricted on the contract, we still want to allow burning and minting - if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { - require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "restricted to TRANSFER_ROLE holders."); + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(transferRole, from) || hasRole(transferRole, to), "!TRANSFER_ROLE"); } if (from == address(0)) { for (uint256 i = 0; i < ids.length; ++i) { totalSupply[ids[i]] += amounts[i]; } + } else { + for (uint256 i = 0; i < ids.length; ++i) { + // pack can no longer be updated after first transfer to non-zero address + if (canUpdatePack[ids[i]]) { + canUpdatePack[ids[i]] = false; + } + } } if (to == address(0)) { diff --git a/contracts/pack/pack.md b/contracts/pack/pack.md index 7533c69ae..a2ae2bfe0 100644 --- a/contracts/pack/pack.md +++ b/contracts/pack/pack.md @@ -63,13 +63,14 @@ And so, what can be packed in packs are *n* number of configurations like ‘500 ```solidity enum TokenType { ERC20, ERC721, ERC1155 } -struct PackContent { +struct Token { address assetContract; TokenType tokenType; uint256 tokenId; - uint256 totalAmountPacked; - uint256 amountDistributedPerOpen; + uint256 totalAmount; } + +uint256 perUnitAmount; ``` | Value | Description | @@ -77,8 +78,8 @@ struct PackContent { | assetContract | The contract address of the token. | | tokenType | The type of the token -- ERC20 / ERC721 / ERC1155 | | tokenId | The tokenId of the the token. (Not applicable for ERC20 tokens. The contract will ignore this value for ERC20 tokens.) | -| totalAmountPacked | The total amount of this token packed in the pack. (Not applicable for ERC721 tokens. The contract will always consider this as 1 for ERC721 tokens.) | -| amountDistributedPerOpen | The amount of this token to distribute as a unit, on opening a pack. (Not applicable for ERC721 tokens. The contract will always consider this as 1 for ERC721 tokens.) | +| totalAmount | The total amount of this token packed in the pack. (Not applicable for ERC721 tokens. The contract will always consider this as 1 for ERC721 tokens.) | +| perUnitAmount | The amount of this token to distribute as a unit, on opening a pack. (Not applicable for ERC721 tokens. The contract will always consider this as 1 for ERC721 tokens.) | **Note:** A pack can contain different configurations for the same token. For example, the same set of packs can contain ‘500 units of 20 USDC’ and ‘10 units of 1000 USDC’ as two independent types of underlying rewards. @@ -87,26 +88,31 @@ struct PackContent { You can create packs with any ERC20, ERC721 or ERC1155 tokens that you own. To create packs, you must specify the following: ```solidity +/// @dev Creates a pack with the stated contents. function createPack( - PackContent[] calldata contents, + Token[] calldata contents, + uint256[] calldata numOfRewardUnits, string calldata packUri, uint128 openStartTimestamp, - uint128 amountDistributedPerOpen -) external; + uint128 amountDistributedPerOpen, + address recipient +) external ``` | Parameter | Description | | --- | --- | -| contents | The reward units packed in the packs. | +| contents | Tokens/assets packed in the set of pack. | +| numOfRewardUnits | Number of reward units for each asset, where each reward unit contains per unit amount of corresponding asset. | | packUri | The (metadata) URI assigned to the packs created. | | openStartTimestamp | The timestamp after which packs can be opened. | | amountDistributedPerOpen | The number of reward units distributed per open. | +| recipient | The recipient of the packs created. | ### Packs are ERC1155 tokens i.e. NFTs Packs themselves are ERC1155 tokens. And so, a set of packs created with your tokens is itself identified by a unique tokenId, has an associated metadata URI and a variable supply. -In the example given in the previous section — ‘How packs work (without web3 terminology)’, there is a set of 100 packs created, where that entire set of packs is identified by a unique tokenId. +In the example given in the previous section — ‘Non technical overview’, there is a set of 100 packs created, where that entire set of packs is identified by a unique tokenId. Since packs are ERC1155 tokens, you can publish multiple sets of packs using the same `Pack` contract. @@ -133,23 +139,24 @@ function openPack(uint256 packId, uint256 amountToOpen) external; ### How reward units are selected to distribute on opening packs -We build on the example in the previous section — ‘How packs work (without web3 terminology)’. +We build on the example in the previous section — ‘Non-technical overview’. -Each single **square**, **circle** or **star** is considered as a ‘reward unit’. For example, the 5 **stars** in the packs may be “5 units of 1000 USDC”, which is represented in the `Pack` contract as a single `PackContent` as follows: +Each single **square**, **circle** or **star** is considered as a ‘reward unit’. For example, the 5 **stars** in the packs may be “5 units of 1000 USDC”, which is represented in the `Pack` contract by the following information ```solidity -struct PackContent { +struct Token { address assetContract; // USDC address TokenType tokenType; // TokenType.ERC20 uint256 tokenId; // Not applicable - uint256 totalAmountPacked; // 5000 - uint256 amountDistributedPerOpen; // 1000 + uint256 totalAmount; // 5000 } + +uint256 perUnitAmount; // 1000 ``` The percentage chance of receiving a particular kind of reward (e.g. a **star**) on opening a pack is calculated as:`(number_of_stars_packed) / (total number of packs)`. Here, `number_of_stars_packed` refers to the total number of reward units of the **star** kind inside the set of packs e.g. a total of 5 units of 1000 USDC. -Going back to the example in the previous section — ‘How packs work (without web3 terminology)’. — the supply of the reward units in the relevant set of packs - 80 **circles**, 15 **squares**, and 5 **stars -** can be represented on a number line, from zero to the total supply of packs - in this case, 100. +Going back to the example in the previous section — ‘Non-technical overview’. — the supply of the reward units in the relevant set of packs - 80 **circles**, 15 **squares**, and 5 **stars -** can be represented on a number line, from zero to the total supply of packs - in this case, 100. ![pack-diag-2.png](/assets/pack-diag-2.png) @@ -181,9 +188,7 @@ The `Pack` contract requires a design where a pack owner *cannot possibly* predi To ensure the above, we make a simple check in the `openPack` function: ```solidity -require(msg.sender == tx.origin, "opener cannot be smart contract"); - -require(_msgSender() == tx.origin, "opener cannot be smart contract"); +require(isTrustedForwarder(msg.sender) || _msgSender() == tx.origin, "opener cannot be smart contract"); ``` `tx.origin` returns the address of the external account that initiated the transaction, of which the `openPack` function call is a part of. @@ -191,7 +196,7 @@ require(_msgSender() == tx.origin, "opener cannot be smart contract"); The above check essentially means that only an external account i.e. an end user wallet, and no smart contract, can open packs. This lets us generate a pseudo random number using block variables, for the purpose of `openPack`: ```solidity -uint256 random = uint(keccak256(abi.encodePacked(msg.sender, blockhash(block.number), block.difficulty))); +uint256 random = uint256(keccak256(abi.encodePacked(_msgSender(), blockhash(block.number - 1), block.difficulty))); ``` Since only end user wallets can open packs, a pack owner *cannot possibly* predict the random number that will be used in the process of their pack opening. That is because a pack opener cannot query the result of the random number calculation during a given block, and call `openPack` within that same block. diff --git a/docs/IPack.md b/docs/IPack.md index 100b14698..4958964c4 100644 --- a/docs/IPack.md +++ b/docs/IPack.md @@ -13,7 +13,7 @@ The thirdweb `Pack` contract is a lootbox mechanism. An account can bundle up ar ### createPack ```solidity -function createPack(ITokenBundle.Token[] contents, uint256[] numOfRewardUnits, string packUri, uint128 openStartTimestamp, uint128 amountDistributedPerOpen, address recipient) external payable returns (uint256 packId, uint256 packTotalSupply) +function createPack(ITokenBundle.Token[] contents, uint256[] numOfRewardUnits, string packUri, uint128 openStartTimestamp, uint128 amountDistributedPerOpen, uint256 expirationTimestamp, address recipient) external payable returns (uint256 packId, uint256 packTotalSupply) ``` @@ -103,5 +103,24 @@ Emitted when a pack is opened. | numOfPacksOpened | uint256 | undefined | | rewardUnitsDistributed | ITokenBundle.Token[] | undefined | +### PackUpdated + +```solidity +event PackUpdated(uint256 indexed packId, address indexed packCreator, address recipient, uint256 totalPacksCreated) +``` + +Emitted when more packs are minted for a packId. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| packId `indexed` | uint256 | undefined | +| packCreator `indexed` | address | undefined | +| recipient | address | undefined | +| totalPacksCreated | uint256 | undefined | + diff --git a/docs/Pack.md b/docs/Pack.md index c3c656d8c..53b55f50e 100644 --- a/docs/Pack.md +++ b/docs/Pack.md @@ -44,6 +44,32 @@ function NATIVE_TOKEN() external view returns (address) |---|---|---| | _0 | address | undefined | +### addPackContents + +```solidity +function addPackContents(uint256 _packId, ITokenBundle.Token[] _contents, uint256[] _numOfRewardUnits, address _recipient) external payable returns (uint256 packTotalSupply, uint256 newSupplyAdded) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _packId | uint256 | undefined +| _contents | ITokenBundle.Token[] | undefined +| _numOfRewardUnits | uint256[] | undefined +| _recipient | address | undefined + +#### Returns + +| Name | Type | Description | +|---|---|---| +| packTotalSupply | uint256 | undefined +| newSupplyAdded | uint256 | undefined + ### balanceOf ```solidity @@ -90,6 +116,28 @@ function balanceOfBatch(address[] accounts, uint256[] ids) external view returns |---|---|---| | _0 | uint256[] | undefined | +### canUpdatePack + +```solidity +function canUpdatePack(uint256) external view returns (bool) +``` + + + +*Checks if pack-creator allowed to add more tokens to a packId; set to false after first transfer* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined + ### contractType ```solidity @@ -144,7 +192,7 @@ function contractVersion() external pure returns (uint8) ### createPack ```solidity -function createPack(ITokenBundle.Token[] _contents, uint256[] _numOfRewardUnits, string _packUri, uint128 _openStartTimestamp, uint128 _amountDistributedPerOpen, address _recipient) external payable returns (uint256 packId, uint256 packTotalSupply) +function createPack(ITokenBundle.Token[] _contents, uint256[] _numOfRewardUnits, string _packUri, uint128 _openStartTimestamp, uint128 _amountDistributedPerOpen, uint256 _expirationTimestamp, address _recipient) external payable returns (uint256 packId, uint256 packTotalSupply) ``` @@ -210,6 +258,29 @@ function getPackContents(uint256 _packId) external view returns (struct ITokenBu | contents | ITokenBundle.Token[] | undefined | | perUnitAmounts | uint256[] | undefined | +### getPackTimestamps + +```solidity +function getPackTimestamps(uint256 _packId) external view returns (uint128 openStartTimestamp, uint256 expirationTimestamp) +``` + + + +*Returns opening and expiration timestamps of a pack.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _packId | uint256 | undefined + +#### Returns + +| Name | Type | Description | +|---|---|---| +| openStartTimestamp | uint128 | undefined +| expirationTimestamp | uint256 | undefined + ### getRoleAdmin ```solidity @@ -433,7 +504,7 @@ Checks whether an account has a particular role; role restricti ### initialize ```solidity -function initialize(address _defaultAdmin, string _name, string _symbol, string _contractURI, address[] _trustedForwarders, address _royaltyRecipient, uint256 _royaltyBps) external nonpayable +function initialize(address _defaultAdmin, string _name, string _symbol, string _contractURI, address _royaltyRecipient, uint256 _royaltyBps) external nonpayable ``` @@ -444,6 +515,14 @@ function initialize(address _defaultAdmin, string _name, string _symbol, string | Name | Type | Description | |---|---|---| +<<<<<<< HEAD +| _defaultAdmin | address | undefined +| _name | string | undefined +| _symbol | string | undefined +| _contractURI | string | undefined +| _royaltyRecipient | address | undefined +| _royaltyBps | uint256 | undefined +======= | _defaultAdmin | address | undefined | | _name | string | undefined | | _symbol | string | undefined | @@ -451,6 +530,7 @@ function initialize(address _defaultAdmin, string _name, string _symbol, string | _trustedForwarders | address[] | undefined | | _royaltyRecipient | address | undefined | | _royaltyBps | uint256 | undefined | +>>>>>>> main ### isApprovedForAll @@ -952,6 +1032,22 @@ function uri(uint256 _tokenId) external view returns (string) |---|---|---| | _0 | string | undefined | +### withdrawUnclaimedAssets + +```solidity +function withdrawUnclaimedAssets(uint256 _packId) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _packId | uint256 | undefined + ## Events @@ -1063,6 +1159,25 @@ Emitted when a pack is opened. | numOfPacksOpened | uint256 | undefined | | rewardUnitsDistributed | ITokenBundle.Token[] | undefined | +### PackUpdated + +```solidity +event PackUpdated(uint256 indexed packId, address indexed packCreator, address recipient, uint256 totalPacksCreated) +``` + +Emitted when more packs are minted for a packId. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| packId `indexed` | uint256 | undefined | +| packCreator `indexed` | address | undefined | +| recipient | address | undefined | +| totalPacksCreated | uint256 | undefined | + ### Paused ```solidity diff --git a/src/test/Multiwrap.t.sol b/src/test/Multiwrap.t.sol index 7377cd040..694703d53 100644 --- a/src/test/Multiwrap.t.sol +++ b/src/test/Multiwrap.t.sol @@ -508,7 +508,7 @@ contract MultiwrapTest is BaseTest { address recipient = address(0x123); vm.prank(address(tokenOwner)); - vm.expectRevert("TokenBundle: no tokens to bind."); + vm.expectRevert("no tokens to bind"); multiwrap.wrap(emptyContent, uriForWrappedToken, recipient); } diff --git a/src/test/Pack.t.sol b/src/test/Pack.t.sol index 7502c9128..a52fdd014 100644 --- a/src/test/Pack.t.sol +++ b/src/test/Pack.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.0; -import { Pack } from "contracts/pack/Pack.sol"; +import { Pack, IERC2981Upgradeable, IERC721Receiver, IERC1155Upgradeable } from "contracts/pack/Pack.sol"; import { IPack } from "contracts/interfaces/IPack.sol"; import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; @@ -12,12 +12,7 @@ import "./utils/BaseTest.sol"; contract PackTest is BaseTest { /// @notice Emitted when a set of packs is created. - event PackCreated( - uint256 indexed packId, - address indexed packCreator, - address recipient, - uint256 totalPacksCreated - ); + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); /// @notice Emitted when a pack is opened. event PackOpened( @@ -32,7 +27,9 @@ contract PackTest is BaseTest { Wallet internal tokenOwner; string internal packUri; ITokenBundle.Token[] internal packContents; + ITokenBundle.Token[] internal additionalContents; uint256[] internal numOfRewardUnits; + uint256[] internal additionalContentsRewardUnits; function setUp() public override { super.setUp(); @@ -147,6 +144,27 @@ contract PackTest is BaseTest { erc1155.mint(address(tokenOwner), 0, 100); erc1155.mint(address(tokenOwner), 1, 500); + // additional contents, to check `addPackContents` + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 2, + totalAmount: 200 + }) + ); + additionalContentsRewardUnits.push(50); + + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + additionalContentsRewardUnits.push(100); + tokenOwner.setAllowanceERC20(address(erc20), address(pack), type(uint256).max); tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), true); tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), true); @@ -165,6 +183,13 @@ contract PackTest is BaseTest { console2.logBytes4(type(IERC1155).interfaceId); } + function test_supportsInterface() public { + assertEq(pack.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC721Receiver).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC1155Receiver).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC1155Upgradeable).interfaceId), true); + } + /** * note: Testing state changes; token owner calls `createPack` to pack owned tokens. */ @@ -267,7 +292,7 @@ contract PackTest is BaseTest { vm.startPrank(address(tokenOwner)); vm.expectEmit(true, true, true, true); - emit PackCreated(packId, address(tokenOwner), recipient, 226); + emit PackCreated(packId, recipient, 226); pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); @@ -492,7 +517,7 @@ contract PackTest is BaseTest { address recipient = address(0x123); vm.startPrank(address(tokenOwner)); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); pack.createPack(invalidContent, rewardUnits, packUri, 0, 1, recipient); } @@ -514,7 +539,7 @@ contract PackTest is BaseTest { address recipient = address(0x123); vm.startPrank(address(tokenOwner)); - vm.expectRevert("amount can't be zero"); + vm.expectRevert("0 amt"); (, uint256 totalSupply) = pack.createPack(invalidContent, rewardUnits, packUri, 0, 1, recipient); // assertEq(totalSupply, 10); @@ -530,7 +555,7 @@ contract PackTest is BaseTest { address recipient = address(0x123); vm.startPrank(address(tokenOwner)); - vm.expectRevert("nothing to pack"); + vm.expectRevert("!Contents"); pack.createPack(emptyContent, rewardUnits, packUri, 0, 1, recipient); } @@ -543,10 +568,176 @@ contract PackTest is BaseTest { address recipient = address(0x123); vm.startPrank(address(tokenOwner)); - vm.expectRevert("invalid reward units"); + vm.expectRevert("!Rewards"); pack.createPack(packContents, rewardUnits, packUri, 0, 1, recipient); } + /*/////////////////////////////////////////////////////////////// + Unit tests: `addPackContents` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; token owner calls `addPackContents` to pack more tokens. + */ + function test_state_addPackContents() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + erc20.mint(address(tokenOwner), 1000 ether); + erc1155.mint(address(tokenOwner), 2, 200); + + vm.prank(address(tokenOwner)); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + + (packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length + additionalContents.length); + for (uint256 i = packContents.length; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, additionalContents[i - packContents.length].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(additionalContents[i - packContents.length].tokenType)); + assertEq(packed[i].tokenId, additionalContents[i - packContents.length].tokenId); + assertEq(packed[i].totalAmount, additionalContents[i - packContents.length].totalAmount); + } + } + + /** + * note: Testing token balances; token owner calls `addPackContents` to pack more tokens + * in an already existing pack. + */ + function test_balances_addPackContents() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(pack)), 2000 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(pack)); + assertEq(erc721.ownerOf(1), address(pack)); + assertEq(erc721.ownerOf(2), address(pack)); + assertEq(erc721.ownerOf(3), address(pack)); + assertEq(erc721.ownerOf(4), address(pack)); + assertEq(erc721.ownerOf(5), address(pack)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(pack), 0), 100); + + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 0); + assertEq(erc1155.balanceOf(address(pack), 1), 500); + + // Pack wrapped token balance + assertEq(pack.balanceOf(address(recipient), packId), totalSupply); + + erc20.mint(address(tokenOwner), 1000 ether); + erc1155.mint(address(tokenOwner), 2, 200); + + vm.prank(address(tokenOwner)); + (uint256 newTotalSupply, uint256 additionalSupply) = pack.addPackContents( + packId, + additionalContents, + additionalContentsRewardUnits, + recipient + ); + + // ERC20 balance after adding more tokens + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(pack)), 3000 ether); + + // ERC1155 balance after adding more tokens + assertEq(erc1155.balanceOf(address(tokenOwner), 2), 0); + assertEq(erc1155.balanceOf(address(pack), 2), 200); + + // Pack wrapped token balance + assertEq(pack.balanceOf(address(recipient), packId), newTotalSupply); + assertEq(totalSupply + additionalSupply, newTotalSupply); + } + + /** + * note: Testing revert condition; non-creator calls `addPackContents`. + */ + function test_revert_addPackContents_NotMinterRole() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + address randomAccount = address(0x123); + + string memory errorMsg = string( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(address(randomAccount)), 20), + " is missing role ", + Strings.toHexString(uint256(keccak256("MINTER_ROLE")), 32) + ) + ); + + vm.prank(randomAccount); + vm.expectRevert(bytes(errorMsg)); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + } + + /** + * note: Testing revert condition; adding tokens to non-existent pack. + */ + function test_revert_addPackContents_PackNonExistent() public { + vm.prank(address(tokenOwner)); + vm.expectRevert("!Allowed"); + pack.addPackContents(0, packContents, numOfRewardUnits, address(1)); + } + + /** + * note: Testing revert condition; adding tokens after packs have been distributed. + */ + function test_revert_addPackContents_CantUpdateAnymore() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.prank(recipient); + pack.safeTransferFrom(recipient, address(567), packId, 1, ""); + + vm.prank(address(tokenOwner)); + vm.expectRevert("!Allowed"); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + } + + /** + * note: Testing revert condition; adding tokens with a different recipient. + */ + function test_revert_addPackContents_NotRecipient() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + address randomRecipient = address(0x12345); + + vm.expectRevert("!Recipient"); + vm.prank(address(tokenOwner)); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, randomRecipient); + } + /*/////////////////////////////////////////////////////////////// Unit tests: `openPack` //////////////////////////////////////////////////////////////*/ @@ -681,7 +872,8 @@ contract PackTest is BaseTest { pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); vm.startPrank(recipient, address(27)); - vm.expectRevert("opener must be eoa"); + string memory err = "!EOA"; + vm.expectRevert(bytes(err)); pack.openPack(packId, 1); } @@ -696,7 +888,7 @@ contract PackTest is BaseTest { (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); vm.startPrank(recipient, recipient); - vm.expectRevert("opening more than owned"); + vm.expectRevert("!Balance"); pack.openPack(packId, totalSupply + 1); } @@ -710,7 +902,7 @@ contract PackTest is BaseTest { pack.createPack(packContents, numOfRewardUnits, packUri, 1000, 1, recipient); vm.startPrank(recipient, recipient); - vm.expectRevert("cannot open yet"); + vm.expectRevert("cant open"); pack.openPack(packId, 1); } @@ -724,7 +916,7 @@ contract PackTest is BaseTest { pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); vm.startPrank(recipient, recipient); - vm.expectRevert("opening more than owned"); + vm.expectRevert("!Balance"); pack.openPack(2, 1); } @@ -803,30 +995,30 @@ contract PackTest is BaseTest { erc1155Amounts = new uint256[](MAX_TOKENS); for (uint256 i = 0; i < rewardUnits.length; i++) { - console2.log("----- reward unit number: ", i, "------"); - console2.log("asset contract: ", rewardUnits[i].assetContract); - console2.log("token type: ", uint256(rewardUnits[i].tokenType)); - console2.log("tokenId: ", rewardUnits[i].tokenId); + // console2.log("----- reward unit number: ", i, "------"); + // console2.log("asset contract: ", rewardUnits[i].assetContract); + // console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + // console2.log("tokenId: ", rewardUnits[i].tokenId); if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { if (rewardUnits[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { - console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); - console.log("balance of recipient: ", address(recipient).balance); + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", address(recipient).balance); nativeTokenAmount += rewardUnits[i].totalAmount; } else { - console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); - console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); erc20Amount += rewardUnits[i].totalAmount; } } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC1155) { - console2.log("total amount: ", rewardUnits[i].totalAmount); - console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); erc1155Amounts[rewardUnits[i].tokenId] += rewardUnits[i].totalAmount; } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC721) { - console2.log("total amount: ", rewardUnits[i].totalAmount); - console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); erc721Amount += rewardUnits[i].totalAmount; } - console2.log(""); + // console2.log(""); } } @@ -876,6 +1068,130 @@ contract PackTest is BaseTest { assertEq(packUri, pack.uri(packId)); } + function test_fuzz_state_openPack( + uint256 x, + uint128 y, + uint256 z + ) public { + // vm.assume(x == 1574 && y == 22 && z == 392); + (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) = getTokensToPack(x); + if (tokensToPack.length == 0) { + return; + } + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + uint256 totalRewardUnits; + uint256 nativeTokenPacked; + + for (uint256 i = 0; i < tokensToPack.length; i += 1) { + totalRewardUnits += rewardUnits[i]; + if (tokensToPack[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + nativeTokenPacked += tokensToPack[i].totalAmount; + } + } + vm.assume(y > 0 && totalRewardUnits % y == 0); + vm.deal(address(tokenOwner), nativeTokenPacked); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack{ value: nativeTokenPacked }( + tokensToPack, + rewardUnits, + packUri, + 0, + y, + recipient + ); + console2.log("total supply: ", totalSupply); + console2.log("total reward units: ", totalRewardUnits); + + vm.assume(z <= totalSupply); + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardsReceived = pack.openPack(packId, z); + console2.log("received reward units: ", rewardsReceived.length); + + assertEq(packUri, pack.uri(packId)); + + ( + uint256 nativeTokenAmount, + uint256 erc20Amount, + uint256[] memory erc1155Amounts, + uint256 erc721Amount + ) = checkBalances(rewardsReceived, recipient); + + assertEq(address(recipient).balance, nativeTokenAmount); + assertEq(erc20.balanceOf(address(recipient)), erc20Amount); + assertEq(erc721.balanceOf(address(recipient)), erc721Amount); + + for (uint256 i = 0; i < erc1155Amounts.length; i += 1) { + assertEq(erc1155.balanceOf(address(recipient), i), erc1155Amounts[i]); + } + } + + function test_fuzz_failing_state_openPack() public { + // x: 446, y: 22, z: 890 (gas: 8937393460518282035), total supply: 10203, total reward units: 224466 + // x: 335, y: 3, z: 1570 (gas: 8937393460517864076), total supply: 54915, total reward units: 164745 + // x: 1962, y: 282, z: 219 (gas: 8937393460523355524), total supply: 3239, total reward units: 913398 + // x: 570, y: 497, z: 435 (gas: 8937393460523355524), total supply: 3239, total reward units: 913398 + + uint256 x = 115; + uint128 y = 191; + uint256 z = 253; + + (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) = getTokensToPack(x); + if (tokensToPack.length == 0) { + return; + } + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + uint256 totalRewardUnits; + uint256 nativeTokenPacked; + + for (uint256 i = 0; i < tokensToPack.length; i += 1) { + totalRewardUnits += rewardUnits[i]; + if (tokensToPack[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + nativeTokenPacked += tokensToPack[i].totalAmount; + } + } + vm.assume(y > 0 && totalRewardUnits % y == 0); + vm.deal(address(tokenOwner), nativeTokenPacked); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack{ value: nativeTokenPacked }( + tokensToPack, + rewardUnits, + packUri, + 0, + y, + recipient + ); + console2.log("total supply: ", totalSupply); + console2.log("total reward units: ", totalRewardUnits); + + vm.assume(z <= totalSupply); + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardsReceived = pack.openPack(packId, z); + console2.log("received reward units: ", rewardsReceived.length); + + // assertEq(packUri, pack.uri(packId)); + + // ( + // uint256 nativeTokenAmount, + // uint256 erc20Amount, + // uint256[] memory erc1155Amounts, + // uint256 erc721Amount + // ) = checkBalances(rewardsReceived, recipient); + + // assertEq(address(recipient).balance, nativeTokenAmount); + // assertEq(erc20.balanceOf(address(recipient)), erc20Amount); + // assertEq(erc721.balanceOf(address(recipient)), erc721Amount); + + // for (uint256 i = 0; i < erc1155Amounts.length; i += 1) { + // assertEq(erc1155.balanceOf(address(recipient), i), erc1155Amounts[i]); + // } + } + /*/////////////////////////////////////////////////////////////// Scenario/Exploit tests //////////////////////////////////////////////////////////////*/ diff --git a/src/test/PackBenchmark.t.sol b/src/test/PackBenchmark.t.sol index f1a9be149..144fcb578 100644 --- a/src/test/PackBenchmark.t.sol +++ b/src/test/PackBenchmark.t.sol @@ -256,3 +256,222 @@ contract OpenPackBenchmarkTest is BaseTest { pack.openPack(0, 1); } } + +contract OpenPackLargeInputsTest is BaseTest { + Pack internal pack; + + Wallet internal tokenOwner; + address recipient = address(0x123); + string internal packUri; + + uint256 packId; + uint256 totalRewardUnits; + uint256 totalSupply; + + uint256 x; + uint128 y; + uint256 z; + + uint256 internal constant MAX_TOKENS = 2000; + + function setUp() public override { + super.setUp(); + + pack = Pack(payable(getContract("Pack"))); + + tokenOwner = getWallet(); + packUri = "ipfs://"; + + tokenOwner.setAllowanceERC20(address(erc20), address(pack), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), true); + + vm.prank(deployer); + pack.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + + // pass (1478, 4, 1890).. (gas: 8937393460521767975), total supply: 177189, total reward units: 708756 + // pass (472, 1, 543).. (gas: 8937393460518373840), total supply: 236220, total reward units: 236220 + // pass (96, 112, 447).. (gas: 8937393460517062690), total supply: 467, total reward units: 52304 + // (506, 6, 12950).. (gas: 8937393460518476945), total supply: 41699, total reward units: 250194 + // pass (164, 20, 922).. (gas: 8937393460517318850), total supply: 4335, total reward units: 86700 + // pass (138, 2, 948).. (gas: 8937393460517220399), total supply: 37959, total reward units: 75918 + // pass (32, 11, 978).. (gas: 8937393460516848598), total supply: 1456, total reward units: 16016 + + // pass x: 446, y: 22, z: 890 (gas: 8937393460518282035), total supply: 10203, total reward units: 224466 + // pass x: 335, y: 3, z: 1570 (gas: 8937393460517864076), total supply: 54915, total reward units: 164745 + // pass x: 1962, y: 282, z: 219 (gas: 8937393460523355524), total supply: 3239, total reward units: 913398 + + // x: 570, y: 497, z: 435 (gas: 8937393460523355524), total supply: 548, total reward units: 272356 + // reverts at rewardUnits = new Token[](numOfRewardUnitsToDistribute); + // x: 412, y: 7, z: 11830 (gas: 8937393460523355524), total supply: 29834, total reward units: 208838 + // reverts while transferring reward units to receipient + // x: 1322, y: 211, z: 1994 (gas: 8937393460523355524), total supply: 3104, total reward units: 6544944 + // reverts at rewardUnits = new Token[](numOfRewardUnitsToDistribute); + // x: 1578, y: 1294, z: 515 (gas: 8937393460523355524), total supply: 580, total reward units: 750520 + // reverts at rewardUnits = new Token[](numOfRewardUnitsToDistribute); + // x: 404, y: 38, z: 3950 (gas: 8937393460523355524), total supply: 5201, total reward units: 197638 + // reverts at rewardUnits = new Token[](numOfRewardUnitsToDistribute); + x = 404; + y = 38; + z = 1700; + + (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) = getTokensToPack(x); + if (tokensToPack.length == 0) { + return; + } + + packId = pack.nextTokenIdToMint(); + uint256 nativeTokenPacked; + + for (uint256 i = 0; i < tokensToPack.length; i += 1) { + totalRewardUnits += rewardUnits[i]; + if (tokensToPack[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + nativeTokenPacked += tokensToPack[i].totalAmount; + } + } + vm.assume(y > 0 && totalRewardUnits % y == 0); + vm.deal(address(tokenOwner), nativeTokenPacked); + + vm.prank(address(tokenOwner)); + (, totalSupply) = pack.createPack{ value: nativeTokenPacked }( + tokensToPack, + rewardUnits, + packUri, + 0, + y, + recipient + ); + + vm.assume(z <= totalSupply); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `openPack` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; pack owner calls `openPack` to redeem underlying rewards. + */ + function test_fuzz_failing_state_openPack() public { + console.log(gasleft()); + console2.log("total supply: ", totalSupply); + console2.log("total reward units: ", totalRewardUnits); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardsReceived = pack.openPack(packId, z); + console2.log("received reward units: ", rewardsReceived.length); + + assertEq(packUri, pack.uri(packId)); + + ( + uint256 nativeTokenAmount, + uint256 erc20Amount, + uint256[] memory erc1155Amounts, + uint256 erc721Amount + ) = checkBalances(rewardsReceived, recipient); + + assertEq(address(recipient).balance, nativeTokenAmount); + assertEq(erc20.balanceOf(address(recipient)), erc20Amount); + assertEq(erc721.balanceOf(address(recipient)), erc721Amount); + + for (uint256 i = 0; i < erc1155Amounts.length; i += 1) { + assertEq(erc1155.balanceOf(address(recipient), i), erc1155Amounts[i]); + } + } + + function getTokensToPack(uint256 len) + internal + returns (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) + { + vm.assume(len < MAX_TOKENS); + tokensToPack = new ITokenBundle.Token[](len); + rewardUnits = new uint256[](len); + + for (uint256 i = 0; i < len; i += 1) { + uint256 random = uint256(keccak256(abi.encodePacked(len + i))) % MAX_TOKENS; + uint256 selector = random % 4; + + if (selector == 0) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: (random + 1) * 10 ether + }); + rewardUnits[i] = random + 1; + + erc20.mint(address(tokenOwner), tokensToPack[i].totalAmount); + } else if (selector == 1) { + uint256 tokenId = erc721.nextTokenIdToMint(); + + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: tokenId, + totalAmount: 1 + }); + rewardUnits[i] = 1; + + erc721.mint(address(tokenOwner), 1); + } else if (selector == 2) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: random, + totalAmount: (random + 1) * 10 + }); + rewardUnits[i] = random + 1; + + erc1155.mint(address(tokenOwner), tokensToPack[i].tokenId, tokensToPack[i].totalAmount); + } else if (selector == 3) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 5 ether + }); + rewardUnits[i] = 5; + } + } + } + + function checkBalances(ITokenBundle.Token[] memory rewardUnits, address recipient) + internal + view + returns ( + uint256 nativeTokenAmount, + uint256 erc20Amount, + uint256[] memory erc1155Amounts, + uint256 erc721Amount + ) + { + erc1155Amounts = new uint256[](MAX_TOKENS); + + for (uint256 i = 0; i < rewardUnits.length; i++) { + // console2.log("----- reward unit number: ", i, "------"); + // console2.log("asset contract: ", rewardUnits[i].assetContract); + // console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + // console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + if (rewardUnits[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", address(recipient).balance); + nativeTokenAmount += rewardUnits[i].totalAmount; + } else { + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); + erc20Amount += rewardUnits[i].totalAmount; + } + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC1155) { + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); + erc1155Amounts[rewardUnits[i].tokenId] += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC721) { + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); + erc721Amount += rewardUnits[i].totalAmount; + } + // console2.log(""); + } + } +} diff --git a/src/test/sdk/extension/TokenBundle.t.sol b/src/test/sdk/extension/TokenBundle.t.sol index 204c54b35..e72e7be75 100644 --- a/src/test/sdk/extension/TokenBundle.t.sol +++ b/src/test/sdk/extension/TokenBundle.t.sol @@ -107,14 +107,14 @@ contract ExtensionTokenBundle is DSTest, Test { function test_revert_createBundle_emptyBundle() public { ITokenBundle.Token[] memory emptyBundle; - vm.expectRevert("TokenBundle: no tokens to bind."); + vm.expectRevert("no tokens to bind"); ext.createBundle(emptyBundle, 0); } function test_revert_createBundle_existingBundleId() public { ext.createBundle(bundleContent, 0); - vm.expectRevert("TokenBundle: existent at bundleId"); + vm.expectRevert("existent at bundleId"); ext.createBundle(bundleContent, 0); } @@ -128,7 +128,7 @@ contract ExtensionTokenBundle is DSTest, Test { }) ); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); ext.createBundle(bundleContent, 0); bundleContent.pop(); @@ -141,7 +141,7 @@ contract ExtensionTokenBundle is DSTest, Test { }) ); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); ext.createBundle(bundleContent, 0); bundleContent.pop(); @@ -154,7 +154,7 @@ contract ExtensionTokenBundle is DSTest, Test { }) ); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); ext.createBundle(bundleContent, 0); bundleContent.pop(); @@ -167,7 +167,7 @@ contract ExtensionTokenBundle is DSTest, Test { }) ); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); ext.createBundle(bundleContent, 0); bundleContent.pop(); @@ -180,7 +180,7 @@ contract ExtensionTokenBundle is DSTest, Test { }) ); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); ext.createBundle(bundleContent, 0); bundleContent.pop(); @@ -193,7 +193,7 @@ contract ExtensionTokenBundle is DSTest, Test { }) ); - vm.expectRevert("Asset doesn't match TokenType"); + vm.expectRevert("!TokenType"); ext.createBundle(bundleContent, 0); } @@ -246,7 +246,7 @@ contract ExtensionTokenBundle is DSTest, Test { ext.createBundle(bundleContent, 0); ITokenBundle.Token[] memory emptyBundle; - vm.expectRevert("TokenBundle: no tokens to bind."); + vm.expectRevert("no tokens to bind"); ext.updateBundle(emptyBundle, 0); } @@ -320,7 +320,7 @@ contract ExtensionTokenBundle is DSTest, Test { totalAmount: 200 }); - vm.expectRevert("TokenBundle: index DNE."); + vm.expectRevert("index DNE"); ext.updateTokenInBundle(newToken, 0, 3); } } diff --git a/src/test/utils/BaseTest.sol b/src/test/utils/BaseTest.sol index 65101cae0..c7ca91147 100644 --- a/src/test/utils/BaseTest.sol +++ b/src/test/utils/BaseTest.sol @@ -9,7 +9,7 @@ import "../mocks/WETH9.sol"; import "../mocks/MockERC20.sol"; import "../mocks/MockERC721.sol"; import "../mocks/MockERC1155.sol"; -import "contracts/Forwarder.sol"; +import { Forwarder } from "contracts/Forwarder.sol"; import "contracts/TWRegistry.sol"; import "contracts/TWFactory.sol"; import { Multiwrap } from "contracts/multiwrap/Multiwrap.sol"; @@ -92,7 +92,7 @@ abstract contract BaseTest is DSTest, Test { TWFactory(factory).addImplementation(address(new Split())); TWFactory(factory).addImplementation(address(new Multiwrap(address(weth)))); TWFactory(factory).addImplementation(address(new MockContract(bytes32("Pack"), 1))); - TWFactory(factory).addImplementation(address(new Pack(address(weth)))); + TWFactory(factory).addImplementation(address(new Pack(address(weth), forwarder))); TWFactory(factory).addImplementation(address(new VoteERC20())); vm.stopPrank(); @@ -226,10 +226,7 @@ abstract contract BaseTest is DSTest, Test { ); deployContractProxy( "Pack", - abi.encodeCall( - Pack.initialize, - (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), royaltyRecipient, royaltyBps) - ) + abi.encodeCall(Pack.initialize, (deployer, NAME, SYMBOL, CONTRACT_URI, royaltyRecipient, royaltyBps)) ); }