diff --git a/contracts/upgradeables/soulbounds/RewardsRouter.sol b/contracts/upgradeables/soulbounds/RewardsRouter.sol new file mode 100644 index 0000000..f1f295e --- /dev/null +++ b/contracts/upgradeables/soulbounds/RewardsRouter.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// @author Summon.xyz Team - https://summon.xyz +// @contributors: [ @ogarciarevett, @karacurt] + +import { + AccessControlUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + PausableUpgradeable +} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { + Initializable +} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +import { LibItems } from "../../libraries/LibItems.sol"; +import { RewardsServer } from "./RewardsServer.sol"; + +/** + * @title RewardsRouter + * @notice Multitenant rewards router: per-tenant RewardsServer; permissionless claim via server signature. + * No AccessToken: users claim rewards directly with a signed message. + */ +contract RewardsRouter is + Initializable, + AccessControlUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable, + UUPSUpgradeable +{ + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + error AddressIsZero(); + error ServerAlreadyExists(); + error ServerDoesNotExist(); + error InvalidServerId(); + error BeaconNotInitialized(); + event ServerBeaconSet(address indexed serverBeacon); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + bytes32 public constant DEV_CONFIG_ROLE = keccak256("DEV_CONFIG_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + + + /*////////////////////////////////////////////////////////////// + STATE + //////////////////////////////////////////////////////////////*/ + + // Per-server registry (one router has many servers). serverId is a small uint8. + mapping(uint8 => address) private servers; + + // Beacons (single implementation per type, upgradeable for all servers) + address public serverBeacon; + + uint256[44] private __gap; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event ServerDeployed(uint8 indexed serverId, address indexed server); + event RewardClaimed(uint8 indexed serverId, address indexed user, uint256 indexed nonce, uint256[] tokenIds); + + /*////////////////////////////////////////////////////////////// + INITIALIZER + //////////////////////////////////////////////////////////////*/ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initializes roles (dev, router, upgrader). Called once by the proxy. + /// @param _devWallet Receives DEFAULT_ADMIN_ROLE, DEV_CONFIG_ROLE, UPGRADER_ROLE. + /// @param _serverImplementation Address of the RewardsServer implementation. + /// @dev _devWallet is a single point of failure: compromise allows router (and beacon) upgrades. Recommend using a multisig (e.g. Gnosis Safe) and timelock for UPGRADER_ROLE actions. See README Security / Operations. + function initialize( + address _devWallet, + address _serverImplementation + ) external initializer { + if (_devWallet == address(0) || _serverImplementation == address(0)) { + revert AddressIsZero(); + } + + __AccessControl_init(); + __Pausable_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _devWallet); + _grantRole(DEV_CONFIG_ROLE, _devWallet); + _grantRole(UPGRADER_ROLE, _devWallet); + _grantRole(MANAGER_ROLE, _devWallet); + + serverBeacon = address(new UpgradeableBeacon(_serverImplementation, _devWallet)); + emit ServerBeaconSet(serverBeacon); + } + + /// @notice Authorizes the upgrade of the RewardsRouter implementation. Only UPGRADER_ROLE. + function _authorizeUpgrade( + address /* newImplementation */ + ) internal override onlyRole(UPGRADER_ROLE) {} + + /*////////////////////////////////////////////////////////////// + PAUSE + //////////////////////////////////////////////////////////////*/ + + /// @notice Pauses all claims. Only MANAGER_ROLE. + function pause() external onlyRole(MANAGER_ROLE) { + _pause(); + } + + /// @notice Unpauses claims. Only MANAGER_ROLE. + function unpause() external onlyRole(MANAGER_ROLE) { + _unpause(); + } + + /*////////////////////////////////////////////////////////////// + BEACON CONFIGURATION + //////////////////////////////////////////////////////////////*/ + + /// @notice Sets the RewardsServer beacon address. Callable once by DEV_CONFIG_ROLE. + /// @param _serverBeacon Address of the UpgradeableBeacon for RewardsServer (deployed off-router; e.g. deployer-owned). + function setServerBeacon(address _serverBeacon) external onlyRole(DEV_CONFIG_ROLE) { + if (address(_serverBeacon) == address(0)) { + revert AddressIsZero(); + } + + serverBeacon = _serverBeacon; + + emit ServerBeaconSet(serverBeacon); + } + + /// @notice Deploys and registers a new RewardsServer for the given serverId. Only MANAGER_ROLE. + /// @dev Caller becomes SERVER_ADMIN_ROLE on the new server. + /// @param serverId Unique server identifier (small uint8). + /// @param serverAdmin Address that receives SERVER_ADMIN_ROLE (signers, withdrawers, transfer). + /// @return server Address of the deployed RewardsServer. + function deployServer(uint8 serverId, address serverAdmin) external nonReentrant onlyRole(MANAGER_ROLE) returns (address server) { + if (serverId == 0) revert InvalidServerId(); + if (servers[serverId] != address(0)) revert ServerAlreadyExists(); + if (serverBeacon == address(0)) revert BeaconNotInitialized(); + + bytes memory initData = abi.encodeWithSelector( + RewardsServer.initialize.selector, + serverAdmin, + serverId + ); + + server = address(new BeaconProxy(serverBeacon, initData)); + + servers[serverId] = server; + emit ServerDeployed(serverId, server); + } + + /// @notice Permissionless claim: anyone may submit; rewards are sent to the beneficiary in the signed data. + /// @dev Caller may be beneficiary or a relayer. Signature is burned (replay protection). + /// @param data ABI-encoded (contractAddress, chainId, beneficiary, userNonce, serverId, tokenIds). + /// @param signature Server signer signature over the claim message. + function claim( + bytes calldata data, + bytes calldata signature + ) external nonReentrant whenNotPaused { + (,, address beneficiary, uint256 userNonce, uint8 serverId, uint256[] memory tokenIds) = decodeClaimData(data); + RewardsServer server = getServer(serverId); + server.claim(data, signature); + emit RewardClaimed(serverId, beneficiary, userNonce, tokenIds); + } + + /*////////////////////////////////////////////////////////////// + VIEW HELPERS (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns full treasury balance view for the server (addresses, total/reserved/available, symbols, names, types, tokenIds). + function getServerTreasuryBalances( + uint8 serverId + ) + external + view + returns ( + address[] memory addresses, + uint256[] memory totalBalances, + uint256[] memory reservedBalances, + uint256[] memory availableBalances, + string[] memory symbols, + string[] memory names, + string[] memory types_, + uint256[] memory tokenIds + ) + { + RewardsServer server = getServer(serverId); + return server.getAllTreasuryBalances(); + } + + /// @notice Returns all reward token ids (item ids) for the server. + function getServerAllItemIds(uint8 serverId) external view returns (uint256[] memory) { + RewardsServer server = getServer(serverId); + return server.getAllItemIds(); + } + + /// @notice Returns reward definitions for a reward token on the server. + function getServerTokenRewards( + uint8 serverId, + uint256 tokenId + ) external view returns (LibItems.Reward[] memory) { + RewardsServer server = getServer(serverId); + return server.getTokenRewards(tokenId); + } + + /// @notice Returns server treasury ERC20 balance for token. + function getServerTreasuryBalance( + uint8 serverId, + address token + ) external view returns (uint256) { + RewardsServer server = getServer(serverId); + return server.getTreasuryBalance(token); + } + + /// @notice Returns reserved amount for token on the server. + function getServerReservedAmount( + uint8 serverId, + address token + ) external view returns (uint256) { + RewardsServer server = getServer(serverId); + return server.getReservedAmount(token); + } + + /// @notice Returns unreserved (available) treasury balance for token on the server. + function getServerAvailableTreasuryBalance( + uint8 serverId, + address token + ) external view returns (uint256) { + RewardsServer server = getServer(serverId); + return server.getAvailableTreasuryBalance(token); + } + + /// @notice Returns whitelisted token addresses for the server. + function getServerWhitelistedTokens( + uint8 serverId + ) external view returns (address[] memory) { + RewardsServer server = getServer(serverId); + return server.getWhitelistedTokens(); + } + + /// @notice Returns whether token is whitelisted on the server. + function isServerWhitelistedToken( + uint8 serverId, + address token + ) external view returns (bool) { + RewardsServer server = getServer(serverId); + return server.isWhitelistedToken(token); + } + + /// @notice Returns whether the reward token exists on the server. + function isTokenExist(uint8 serverId, uint256 tokenId) public view returns (bool) { + RewardsServer server = getServer(serverId); + return server.isTokenExists(tokenId); + } + + /// @notice Returns structured reward token details (URI, maxSupply, reward types/amounts/addresses/tokenIds). + function getTokenDetails( + uint8 serverId, + uint256 tokenId + ) + external + view + returns ( + string memory tokenUri, + uint256 maxSupply, + LibItems.RewardType[] memory rewardTypes, + uint256[] memory rewardAmounts, + address[] memory rewardTokenAddresses, + uint256[][] memory rewardTokenIds, + uint256[] memory rewardTokenId + ) + { + RewardsServer server = getServer(serverId); + return server.getTokenDetails(tokenId); + } + + /// @notice Returns remaining claimable supply for a reward token (maxSupply - currentSupply), or 0 if exhausted/nonexistent. + function getRemainingSupply( + uint8 serverId, + uint256 tokenId + ) external view returns (uint256) { + RewardsServer server = getServer(serverId); + return server.getRemainingRewardSupply(tokenId); + } + + /// @notice Returns list of all active signer addresses for the server (rewards-get-whitelist-signers). + function getServerSigners(uint8 serverId) external view returns (address[] memory) { + RewardsServer server = getServer(serverId); + return server.getSigners(); + } + + /// @notice Returns the RewardsServer address for a server. + function getServer(uint8 serverId) public view returns (RewardsServer) { + address serverAddress = servers[serverId]; + if (serverAddress == address(0)) revert ServerDoesNotExist(); + return RewardsServer(payable(serverAddress)); + } + + /// @notice Decodes claim data for debugging. Same encoding as used in claim(serverId, data, nonce, signature). + function decodeClaimData( + bytes calldata data + ) public pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 userNonce, uint8 serverId, uint256[] memory tokenIds) { + (contractAddress, chainId, beneficiary, userNonce, serverId, tokenIds) = + abi.decode(data, (address, uint256, address, uint256, uint8, uint256[])); + } + + receive() external payable {} +} + diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol new file mode 100644 index 0000000..3acadd4 --- /dev/null +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -0,0 +1,996 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// @author Summon.xyz Team - https://summon.xyz +// @contributors: [ @ogarciarevett, @karacurt ] + +import { IERC1155 } from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { ERC721HolderUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/utils/ERC721HolderUpgradeable.sol"; +import { ERC1155HolderUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC1155/utils/ERC1155HolderUpgradeable.sol"; +import { LibItems } from "../../libraries/LibItems.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { + MessageHashUtils +} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + + +interface IERC1155Metadata { + function name() external view returns (string memory); + function symbol() external view returns (string memory); +} + +/** + * @title RewardsServer + * @notice Per-server rewards contract: holds assets, whitelist, and reward reserve control. + * @dev One RewardsServer per server; reward token definitions, supply, and reservations live here. + * Upgraded via UpgradeableBeacon (BeaconProxy); no per-instance UUPS. + * ERC721 reward supply cannot be increased after creation; when exhausted, create a new reward token. + */ +contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderUpgradeable, ERC1155HolderUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable { + + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + error AddressIsZero(); + error InvalidAmount(); + error TokenNotWhitelisted(); + error TokenAlreadyWhitelisted(); + error RewardTokenAlreadyExists(); + error TokenNotExist(); + error InsufficientTreasuryBalance(); + error TokenHasReserves(); + error InsufficientBalance(); + error InsufficientERC721Ids(); + error ExceedMaxSupply(); + error InvalidInput(); + error DupTokenId(); + error TransferFailed(); + error InvalidLength(); + error NonceAlreadyUsed(); + error InvalidSignature(); + error SignerAlreadySet(); + error InvalidServerId(); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + bytes32 public constant SERVER_ADMIN_ROLE = keccak256("SERVER_ADMIN_ROLE"); + + /*////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////*/ + + // Server-level access (admin, signers for claim) + mapping(address => bool) public signers; + address[] private signerList; + + // Whitelist + mapping(address => bool) public whitelistedTokens; + address[] private whitelistedTokenList; + mapping(address => LibItems.RewardType) public tokenTypes; + + // Reservations (for rewards) + mapping(address => uint256) public reservedAmounts; + mapping(address => mapping(uint256 => bool)) public isErc721Reserved; + mapping(address => uint256) public erc721TotalReserved; + mapping(address => mapping(uint256 => uint256)) public erc1155ReservedAmounts; + mapping(address => uint256) public erc1155TotalReserved; + + // Reward token state + uint256[] public itemIds; + mapping(uint256 => bool) public tokenExists; + mapping(uint256 => LibItems.RewardToken) public tokenRewards; + mapping(uint256 => mapping(uint256 => uint256)) public erc721RewardCurrentIndex; + mapping(uint256 => uint256) public currentRewardSupply; + + // Per-user nonce (for mint/claim signatures) + mapping(address => mapping(uint256 => bool)) public isUserNonceUsed; + + // ETH reserved for pending ETHER rewards (this server holds all its treasury ETH) + uint256 public ethReservedTotal; + + uint8 public id; + + uint256[28] private __gap; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + event TreasuryDeposit(address indexed token, uint256 amount); + event SignerUpdated(address indexed account, bool active); + event AssetsWithdrawn( + LibItems.RewardType rewardType, + address indexed to, + uint256 amount + ); + /// @param oldSupply Previous max supply (reduceRewardSupply) or current claim count (increaseRewardSupply). + /// @param newSupply New max supply after the change. + event RewardSupplyChanged( + uint256 indexed tokenId, + uint256 oldSupply, + uint256 newSupply + ); + event Claimed( + address indexed to, + uint256 indexed tokenId + ); + event UserNonceUsed(address indexed user, uint256 indexed nonce); + + /*////////////////////////////////////////////////////////////// + INITIALIZER + //////////////////////////////////////////////////////////////*/ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initializes the server: access roles and server admin. Called once by the proxy. + /// @param _serverAdmin Address that receives SERVER_ADMIN_ROLE (signers, withdrawers, transfer). + /// @param _id Server id (must match router's serverId for this instance). + function initialize(address _serverAdmin, uint8 _id) external initializer { + if (_serverAdmin == address(0)) { + revert AddressIsZero(); + } + + __AccessControl_init(); + __ERC721Holder_init(); + __ERC1155Holder_init(); + __Pausable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _serverAdmin); + _grantRole(SERVER_ADMIN_ROLE, _serverAdmin); + id = _id; + } + + /*////////////////////////////////////////////////////////////// + SERVER ACCESS CONTROL (admin, signers) + //////////////////////////////////////////////////////////////*/ + + + /// @notice Same as setSigner but called by RewardsRouter; caller must be SERVER_ADMIN_ROLE. + function setSigner(address account, bool active) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + if (account == address(0)) revert AddressIsZero(); + if (signers[account] == active) revert SignerAlreadySet(); + + if (active) { + signers[account] = true; + signerList.push(account); + } else { + signers[account] = false; + for (uint256 i = 0; i < signerList.length; i++) { + if (signerList[i] == account) { + signerList[i] = signerList[signerList.length - 1]; + signerList.pop(); + emit SignerUpdated(account, false); + return; + } + } + } + emit SignerUpdated(account, active); + } + + /*////////////////////////////////////////////////////////////// + TREASURY MANAGEMENT FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @notice Adds a token to the whitelist for use in rewards. Only SERVER_ADMIN_ROLE. + /// @param _token Token contract address. + /// @param _type One of ERC20, ERC721, ERC1155. + function whitelistToken(address _token, LibItems.RewardType _type) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + if (_token == address(0)) revert AddressIsZero(); + if (whitelistedTokens[_token]) revert TokenAlreadyWhitelisted(); + + whitelistedTokens[_token] = true; + tokenTypes[_token] = _type; + whitelistedTokenList.push(_token); + + if (_type == LibItems.RewardType.ERC20) { + reservedAmounts[_token] = 0; + } + } + + /// @notice Removes a token from the whitelist. Fails if the token has any reserves. Only SERVER_ADMIN_ROLE. + /// @param _token Token contract address. + function removeTokenFromWhitelist(address _token) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + LibItems.RewardType _type = tokenTypes[_token]; + if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); + + if (_type == LibItems.RewardType.ERC20 && reservedAmounts[_token] > 0) revert TokenHasReserves(); + if (_type == LibItems.RewardType.ERC721 && erc721TotalReserved[_token] > 0) revert TokenHasReserves(); + if (_type == LibItems.RewardType.ERC1155 && erc1155TotalReserved[_token] > 0) revert TokenHasReserves(); + + whitelistedTokens[_token] = false; + delete tokenTypes[_token]; + for (uint256 i = 0; i < whitelistedTokenList.length; i++) { + if (whitelistedTokenList[i] == _token) { + whitelistedTokenList[i] = whitelistedTokenList[whitelistedTokenList.length - 1]; + whitelistedTokenList.pop(); + break; + } + } + } + + /// @notice Withdraws assets from server treasury to recipient. Caller must be SERVER_ADMIN_ROLE on the server. ETHER: amounts[0]; ERC721: tokenIds; ERC1155: tokenIds + amounts. + function withdrawAssets( + LibItems.RewardType rewardType, + address to, + address tokenAddress, + uint256[] calldata tokenIds, + uint256[] calldata amounts + ) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + if (to == address(0)) revert AddressIsZero(); + + if (rewardType == LibItems.RewardType.ETHER) { + if (amounts.length == 0) revert InvalidInput(); + withdrawEtherUnreservedTreasury(to, amounts[0]); + } else if (rewardType == LibItems.RewardType.ERC20) { + withdrawUnreservedTreasury(tokenAddress, to); + } else if (rewardType == LibItems.RewardType.ERC721) { + for (uint256 i = 0; i < tokenIds.length; i++) { + withdrawERC721UnreservedTreasury(tokenAddress, to, tokenIds[i]); + } + } else if (rewardType == LibItems.RewardType.ERC1155) { + if (tokenIds.length != amounts.length) revert InvalidLength(); + for (uint256 i = 0; i < tokenIds.length; i++) { + withdrawERC1155UnreservedTreasury(tokenAddress, to, tokenIds[i], amounts[i]); + } + } + uint256 emittedAmount = rewardType == LibItems.RewardType.ERC721 + ? tokenIds.length + : (amounts.length > 0 ? amounts[0] : 0); + emit AssetsWithdrawn(rewardType, to, emittedAmount); + } + + /// @notice Sends all unreserved ERC20 balance of _token to _to. Only SERVER_ADMIN_ROLE. + function withdrawUnreservedTreasury(address _token, address _to) public onlyRole(SERVER_ADMIN_ROLE) { + if (_to == address(0)) revert AddressIsZero(); + if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); + + uint256 balance = IERC20(_token).balanceOf(address(this)); + uint256 reserved = reservedAmounts[_token]; + + if (balance <= reserved) revert InsufficientBalance(); + + SafeERC20.safeTransfer(IERC20(_token), _to, balance - reserved); + } + + /// @notice Sends one unreserved ERC721 token to _to. Only SERVER_ADMIN_ROLE. Fails if _tokenId is reserved. + function withdrawERC721UnreservedTreasury(address _token, address _to, uint256 _tokenId) public onlyRole(SERVER_ADMIN_ROLE) { + if (_to == address(0)) revert AddressIsZero(); + if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); + if (isErc721Reserved[_token][_tokenId]) revert InsufficientTreasuryBalance(); + + IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); + } + + /// @notice Sends unreserved ERC1155 amount to _to. Only SERVER_ADMIN_ROLE. + function withdrawERC1155UnreservedTreasury(address _token, address _to, uint256 _tokenId, uint256 _amount) public onlyRole(SERVER_ADMIN_ROLE) { + if (_to == address(0)) revert AddressIsZero(); + if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); + + uint256 balance = IERC1155(_token).balanceOf(address(this), _tokenId); + uint256 reserved = erc1155ReservedAmounts[_token][_tokenId]; + + if (balance <= reserved) revert InsufficientBalance(); + if (_amount > (balance - reserved)) revert InsufficientBalance(); + + IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); + } + + /// @notice Sends unreserved ETH from this server's treasury to _to. Only SERVER_ADMIN_ROLE. + function withdrawEtherUnreservedTreasury(address _to, uint256 _amount) public onlyRole(SERVER_ADMIN_ROLE) { + if (_to == address(0)) revert AddressIsZero(); + uint256 available = address(this).balance - ethReservedTotal; + if (_amount > available) revert InsufficientBalance(); + (bool ok, ) = payable(_to).call{ value: _amount }(""); + if (!ok) revert TransferFailed(); + } + + /*////////////////////////////////////////////////////////////// + DISTRIBUTION FUNCTIONS (for claims) + //////////////////////////////////////////////////////////////*/ + + /// @notice Permissionless claim: anyone may submit; rewards are sent to the beneficiary in the signed data. + /// @dev Caller may be beneficiary or a relayer. Signature is burned (replay protection). + /// @param data ABI-encoded (contractAddress, chainId, beneficiary, userNonce, tokenIds). + /// @param signature Server signer signature over the claim message. + function claim( + bytes calldata data, + bytes calldata signature + ) external nonReentrant whenNotPaused { + ( + address contractAddress, + uint256 chainId, + address beneficiary, + uint256 userNonce, + uint8 serverId, + uint256[] memory tokenIds + ) = decodeClaimData(data); + + if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); + + _verifyServerSignature(serverId, beneficiary, tokenIds, userNonce, signature); + + for (uint256 i = 0; i < tokenIds.length; i++) { + _claimReward(beneficiary, tokenIds[i]); + } + } + + + /// @notice Validates and creates a new reward token; reserves ERC20/721/1155 and ETH on this server. Only SERVER_ADMIN_ROLE. Send ETH if token has ETHER rewards. + function createTokenAndReserveRewards(LibItems.RewardToken calldata token) external payable nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + if (token.maxSupply == 0) revert InvalidAmount(); + if ( + bytes(token.tokenUri).length == 0 || + token.rewards.length == 0 || + token.tokenId == 0 + ) revert InvalidInput(); + if (tokenExists[token.tokenId]) revert DupTokenId(); + + uint256 ethRequired; + for (uint256 i = 0; i < token.rewards.length; i++) { + LibItems.Reward memory r = token.rewards[i]; + if (r.rewardType != LibItems.RewardType.ETHER && r.rewardTokenAddress == address(0)) revert AddressIsZero(); + if (r.rewardType == LibItems.RewardType.ETHER) { + ethRequired += r.rewardAmount * token.maxSupply; + } else if (r.rewardType == LibItems.RewardType.ERC721) { + if ( + r.rewardTokenIds.length == 0 || + r.rewardTokenIds.length != r.rewardAmount * token.maxSupply + ) revert InvalidInput(); + } + if (r.rewardType != LibItems.RewardType.ERC721 && r.rewardAmount == 0) revert InvalidAmount(); + } + if (ethRequired > 0) { + if (msg.value < ethRequired) revert InsufficientBalance(); + ethReservedTotal += ethRequired; + } + + for (uint256 i = 0; i < token.rewards.length; i++) { + LibItems.Reward memory r = token.rewards[i]; + if (r.rewardType == LibItems.RewardType.ERC20) { + if (!whitelistedTokens[r.rewardTokenAddress]) revert TokenNotWhitelisted(); + uint256 totalAmount = r.rewardAmount * token.maxSupply; + uint256 balance = IERC20(r.rewardTokenAddress).balanceOf(address(this)); + uint256 reserved = reservedAmounts[r.rewardTokenAddress]; + if (balance < reserved + totalAmount) revert InsufficientTreasuryBalance(); + reservedAmounts[r.rewardTokenAddress] += totalAmount; + } else if (r.rewardType == LibItems.RewardType.ERC721) { + if (!whitelistedTokens[r.rewardTokenAddress]) revert TokenNotWhitelisted(); + IERC721 nft = IERC721(r.rewardTokenAddress); + for (uint256 j = 0; j < r.rewardTokenIds.length; j++) { + uint256 tid = r.rewardTokenIds[j]; + if (nft.ownerOf(tid) != address(this) || isErc721Reserved[r.rewardTokenAddress][tid]) { + revert InsufficientTreasuryBalance(); + } + } + for (uint256 j = 0; j < r.rewardTokenIds.length; j++) { + isErc721Reserved[r.rewardTokenAddress][r.rewardTokenIds[j]] = true; + erc721TotalReserved[r.rewardTokenAddress]++; + } + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + if (!whitelistedTokens[r.rewardTokenAddress]) revert TokenNotWhitelisted(); + uint256 totalAmount = r.rewardAmount * token.maxSupply; + uint256 balance = IERC1155(r.rewardTokenAddress).balanceOf(address(this), r.rewardTokenId); + uint256 reserved = erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId]; + if (balance < reserved + totalAmount) revert InsufficientTreasuryBalance(); + erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId] += totalAmount; + erc1155TotalReserved[r.rewardTokenAddress] += totalAmount; + } + } + + LibItems.RewardToken memory tokenMem = token; + _addRewardToken(token.tokenId, tokenMem); + } + + /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155/ERC721 reservations. Only SERVER_ADMIN_ROLE. + /// @dev New max supply must not be below currentRewardSupply. ERC721: tail NFT IDs (no longer claimable) are un-reserved so they can be withdrawn. + /// @param _tokenId Reward token id. + /// @param _reduceBy Amount to subtract from max supply (must be > 0). + function reduceRewardSupply(uint256 _tokenId, uint256 _reduceBy) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + if (!tokenExists[_tokenId]) revert TokenNotExist(); + if (_reduceBy == 0) revert InvalidAmount(); + + LibItems.RewardToken memory rewardToken = tokenRewards[_tokenId]; + uint256 current = currentRewardSupply[_tokenId]; + uint256 oldMaxSupply = rewardToken.maxSupply; + uint256 newSupply = oldMaxSupply - _reduceBy; + if (current > newSupply) revert InsufficientBalance(); + + for (uint256 i = 0; i < rewardToken.rewards.length; i++) { + LibItems.Reward memory r = rewardToken.rewards[i]; + if (r.rewardType == LibItems.RewardType.ETHER) { + ethReservedTotal -= r.rewardAmount * _reduceBy; + } else if (r.rewardType == LibItems.RewardType.ERC20) { + uint256 releaseAmount = r.rewardAmount * _reduceBy; + reservedAmounts[r.rewardTokenAddress] -= releaseAmount; + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + uint256 releaseAmount = r.rewardAmount * _reduceBy; + erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId] -= releaseAmount; + erc1155TotalReserved[r.rewardTokenAddress] -= releaseAmount; + } else if (r.rewardType == LibItems.RewardType.ERC721) { + uint256 startIndex = newSupply * r.rewardAmount; + for (uint256 j = startIndex; j < r.rewardTokenIds.length; j++) { + uint256 nftId = r.rewardTokenIds[j]; + isErc721Reserved[r.rewardTokenAddress][nftId] = false; + erc721TotalReserved[r.rewardTokenAddress]--; + } + } + } + + rewardToken.maxSupply = newSupply; + tokenRewards[_tokenId] = rewardToken; + + emit RewardSupplyChanged(_tokenId, oldMaxSupply, newSupply); + } + + /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH on this server. Only SERVER_ADMIN_ROLE. Send ETH if token has ETHER rewards. + /// @dev ERC721-backed rewards cannot have supply increased: rewardTokenIds length is fixed at creation. When ERC721 supply is exhausted, create a new reward token. + /// @param _tokenId Reward token id. + /// @param _additionalSupply Extra supply to add (must be > 0). For ERC721 rewards this will revert. + function increaseRewardSupply(uint256 _tokenId, uint256 _additionalSupply) external payable nonReentrant onlyRole(SERVER_ADMIN_ROLE) { + if (!tokenExists[_tokenId]) revert TokenNotExist(); + if (_additionalSupply == 0) revert InvalidAmount(); + + uint256 additionalEthRequired = this.getEthRequiredForIncreaseSupply(_tokenId, _additionalSupply); + if (additionalEthRequired > 0) { + if (msg.value < additionalEthRequired) revert InsufficientBalance(); + ethReservedTotal += additionalEthRequired; + } + + LibItems.RewardToken memory rewardToken = tokenRewards[_tokenId]; + uint256 newSupply = rewardToken.maxSupply + _additionalSupply; + + for (uint256 i = 0; i < rewardToken.rewards.length; i++) { + LibItems.Reward memory r = rewardToken.rewards[i]; + if (r.rewardType == LibItems.RewardType.ERC721) { + if (r.rewardTokenIds.length < r.rewardAmount * newSupply) revert InsufficientERC721Ids(); + } else if (r.rewardType == LibItems.RewardType.ERC20) { + uint256 addAmount = r.rewardAmount * _additionalSupply; + uint256 balance = IERC20(r.rewardTokenAddress).balanceOf(address(this)); + uint256 reserved = reservedAmounts[r.rewardTokenAddress]; + if (balance < reserved + addAmount) revert InsufficientTreasuryBalance(); + reservedAmounts[r.rewardTokenAddress] += addAmount; + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + uint256 addAmount = r.rewardAmount * _additionalSupply; + uint256 balance = IERC1155(r.rewardTokenAddress).balanceOf(address(this), r.rewardTokenId); + uint256 reserved = erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId]; + if (balance < reserved + addAmount) revert InsufficientTreasuryBalance(); + erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId] += addAmount; + erc1155TotalReserved[r.rewardTokenAddress] += addAmount; + } + } + + rewardToken.maxSupply = newSupply; + tokenRewards[_tokenId] = rewardToken; + + emit RewardSupplyChanged(_tokenId, currentRewardSupply[_tokenId], newSupply); + } + + /*////////////////////////////////////////////////////////////// + PAUSE + //////////////////////////////////////////////////////////////*/ + + /// @notice Pauses all claims. Only SERVER_ADMIN_ROLE. + function pause() external onlyRole(SERVER_ADMIN_ROLE) { + _pause(); + } + + /// @notice Unpauses claims. Only SERVER_ADMIN_ROLE. + function unpause() external onlyRole(SERVER_ADMIN_ROLE) { + _unpause(); + } + + /*////////////////////////////////////////////////////////////// + TREASURY VIEW FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns aggregated treasury view: addresses, total/reserved/available balances, symbols, names, types, tokenIds for ERC1155. + /// @return addresses Token addresses (whitelisted ERC20/721 plus one per ERC1155 token id). + /// @return totalBalances Total balance per entry. + /// @return reservedBalances Reserved amount per entry. + /// @return availableBalances total - reserved per entry. + /// @return symbols Token symbols where available. + /// @return names Token names where available. + /// @return types "fa" for ERC20/721, "1155" for ERC1155. + /// @return tokenIds For ERC1155 entries the token id; 0 for ERC20/721. + function getAllTreasuryBalances() + external + view + returns ( + address[] memory addresses, + uint256[] memory totalBalances, + uint256[] memory reservedBalances, + uint256[] memory availableBalances, + string[] memory symbols, + string[] memory names, + string[] memory types, + uint256[] memory tokenIds + ) + { + address[] memory whitelistedTokensArray = whitelistedTokenList; + + uint256 erc20AndErc721Count = 0; + for (uint256 i = 0; i < whitelistedTokensArray.length; i++) { + LibItems.RewardType tokenType = tokenTypes[whitelistedTokensArray[i]]; + if (tokenType == LibItems.RewardType.ERC20 || tokenType == LibItems.RewardType.ERC721) { + erc20AndErc721Count++; + } + } + + uint256 erc1155Count = _countUniqueErc1155TokenIds(); + uint256 totalCount = erc20AndErc721Count + erc1155Count; + + addresses = new address[](totalCount); + totalBalances = new uint256[](totalCount); + reservedBalances = new uint256[](totalCount); + availableBalances = new uint256[](totalCount); + symbols = new string[](totalCount); + names = new string[](totalCount); + types = new string[](totalCount); + tokenIds = new uint256[](totalCount); + + uint256 currentIndex = 0; + + for (uint256 i = 0; i < whitelistedTokensArray.length; i++) { + address tokenAddress = whitelistedTokensArray[i]; + LibItems.RewardType tokenType = tokenTypes[tokenAddress]; + + addresses[currentIndex] = tokenAddress; + + if (tokenType == LibItems.RewardType.ERC20) { + _processERC20Token(tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); + tokenIds[currentIndex] = 0; + currentIndex++; + } else if (tokenType == LibItems.RewardType.ERC721) { + _processERC721Token(tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); + tokenIds[currentIndex] = 0; + currentIndex++; + } + } + + currentIndex = _processERC1155Tokens(erc1155Count, currentIndex, addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds); + + return (addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds); + } + + /// @notice ERC20 balance of this contract for _token. + function getTreasuryBalance(address _token) external view returns (uint256) { + return IERC20(_token).balanceOf(address(this)); + } + + /// @notice Reserved amount for _token. + function getReservedAmount(address _token) external view returns (uint256) { + return reservedAmounts[_token]; + } + + /// @notice Unreserved ERC20 balance for _token. + function getAvailableTreasuryBalance(address _token) external view returns (uint256) { + uint256 balance = IERC20(_token).balanceOf(address(this)); + uint256 reserved = reservedAmounts[_token]; + return balance > reserved ? balance - reserved : 0; + } + + /// @notice List of whitelisted token addresses. + function getWhitelistedTokens() external view returns (address[] memory) { + return whitelistedTokenList; + } + + /// @notice Returns remaining claimable supply for a reward token (maxSupply - currentSupply), or 0 if exhausted/nonexistent. + function getRemainingRewardSupply( + uint256 tokenId + ) external view returns (uint256) { + if (!tokenExists[tokenId]) return 0; + uint256 maxSupply = tokenRewards[tokenId].maxSupply; + uint256 current = currentRewardSupply[tokenId]; + if (current >= maxSupply) return 0; + return maxSupply - current; + } + + /// @notice Whether _token is whitelisted. + function isWhitelistedToken(address _token) external view returns (bool) { + return whitelistedTokens[_token]; + } + + /*////////////////////////////////////////////////////////////// + REWARD RESERVE & VIEW + //////////////////////////////////////////////////////////////*/ + + /// @notice All reward token ids (item ids) defined on this server. + function getAllItemIds() external view returns (uint256[] memory) { + return itemIds; + } + + /// @notice Reward definitions for a given reward token id. + function getTokenRewards(uint256 _tokenId) external view returns (LibItems.Reward[] memory) { + return tokenRewards[_tokenId].rewards; + } + + /// @notice Full reward token struct for _tokenId (rewards, maxSupply, etc.). + function getRewardToken(uint256 _tokenId) external view returns (LibItems.RewardToken memory) { + return tokenRewards[_tokenId]; + } + + /// @notice ETH required to increase supply by _additionalSupply for a reward token that has ETHER rewards. + function getEthRequiredForIncreaseSupply(uint256 _tokenId, uint256 _additionalSupply) external view returns (uint256) { + if (!tokenExists[_tokenId] || _additionalSupply == 0) return 0; + LibItems.RewardToken memory rewardToken = tokenRewards[_tokenId]; + uint256 additionalEthRequired; + for (uint256 i = 0; i < rewardToken.rewards.length; i++) { + if (rewardToken.rewards[i].rewardType == LibItems.RewardType.ETHER) { + additionalEthRequired += rewardToken.rewards[i].rewardAmount * _additionalSupply; + } + } + return additionalEthRequired; + } + + /// @notice Returns structured reward token details (URI, maxSupply, reward types/amounts/addresses/tokenIds). + function getTokenDetails(uint256 _tokenId) + external + view + returns ( + string memory tokenUri, + uint256 maxSupply, + LibItems.RewardType[] memory rewardTypes, + uint256[] memory rewardAmounts, + address[] memory rewardTokenAddresses, + uint256[][] memory rewardTokenIds, + uint256[] memory rewardTokenId + ) + { + LibItems.RewardToken memory rt = tokenRewards[_tokenId]; + tokenUri = rt.tokenUri; + maxSupply = rt.maxSupply; + LibItems.Reward[] memory rewards = rt.rewards; + rewardTypes = new LibItems.RewardType[](rewards.length); + rewardAmounts = new uint256[](rewards.length); + rewardTokenAddresses = new address[](rewards.length); + rewardTokenIds = new uint256[][](rewards.length); + rewardTokenId = new uint256[](rewards.length); + for (uint256 i = 0; i < rewards.length; i++) { + rewardTypes[i] = rewards[i].rewardType; + rewardAmounts[i] = rewards[i].rewardAmount; + rewardTokenAddresses[i] = rewards[i].rewardTokenAddress; + rewardTokenIds[i] = rewards[i].rewardTokenIds; + rewardTokenId[i] = rewards[i].rewardTokenId; + } + } + + /// @notice Decodes claim data for debugging. Same encoding as used in claim(data, signature). + /// @dev Decoded data order: (contractAddress, chainId, beneficiary, userNonce, serverId, tokenIds). + /// Hash for signing uses a different order: (contractAddress, chainId, serverId, beneficiary, userNonce, tokenIds). See _verifyServerSignature. + function decodeClaimData( + bytes calldata data + ) public pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 userNonce, uint8 serverId, uint256[] memory tokenIds) { + (contractAddress, chainId, beneficiary, userNonce, serverId, tokenIds) = + abi.decode(data, (address, uint256, address, uint256, uint8, uint256[])); + } + + /// @notice Whether a reward token with _tokenId exists. + function isTokenExists(uint256 _tokenId) external view returns (bool) { + return tokenExists[_tokenId]; + } + + /// @notice Current distribution index for an ERC721 reward slot (used to pick next token id). + function getERC721RewardCurrentIndex(uint256 _rewardTokenId, uint256 _rewardIndex) external view returns (uint256) { + return erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex]; + } + + /// @notice Returns list of all active signer addresses (for rewards-get-whitelist-signers). + function getSigners() external view returns (address[] memory) { + return signerList; + } + + + function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControlUpgradeable, ERC1155HolderUpgradeable) returns (bool) { + return super.supportsInterface(interfaceId); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Internal helper to verify a server-scoped signature. + * + * Hash encoding order (for signing): contractAddress, chainId, serverId, beneficiary, userNonce, tokenIds. + * Decoded claim data order (decodeClaimData / ABI of data): contractAddress, chainId, beneficiary, userNonce, serverId, tokenIds. + * SDKs must use the hash order above when building the sign message; using the decode order will produce invalid signatures. + * Message format: keccak256(abi.encode(...)) then EIP-191 prefix. + */ + function _verifyServerSignature( + uint8 serverId, + address beneficiary, + uint256[] memory tokenIds, + uint256 userNonce, + bytes calldata signature + ) internal { + uint256 currentChainId = block.chainid; + bytes32 message = keccak256( + abi.encode( + address(this), + currentChainId, + serverId, + beneficiary, + userNonce, + tokenIds + ) + ); + bytes32 hash = MessageHashUtils.toEthSignedMessageHash(message); + address signer = ECDSA.recover(hash, signature); + + if (!signers[signer]) { + revert InvalidSignature(); + } + if (serverId != id) revert InvalidServerId(); + if (isUserNonceUsed[beneficiary][userNonce]) revert NonceAlreadyUsed(); + isUserNonceUsed[beneficiary][userNonce] = true; + emit UserNonceUsed(beneficiary, userNonce); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPER FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + function _addRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) internal { + if (tokenExists[_tokenId]) revert RewardTokenAlreadyExists(); + tokenExists[_tokenId] = true; + tokenRewards[_tokenId] = _rewardToken; + itemIds.push(_tokenId); + currentRewardSupply[_tokenId] = 0; + } + + /// @notice Fulfills claim for a reward token: distributes rewards (including ETH from this server's treasury) to to and increments supply. Only REWARDS_ROUTER_ROLE (Router calls this from claim()). + function _claimReward(address to, uint256 tokenId) internal { + if (to == address(0)) revert AddressIsZero(); + if (!tokenExists[tokenId]) revert TokenNotExist(); + + if (currentRewardSupply[tokenId] + 1 > tokenRewards[tokenId].maxSupply) revert ExceedMaxSupply(); + + _distributeReward(to, tokenId); + + currentRewardSupply[tokenId]++; + + emit Claimed(to, tokenId); + } + + /// @notice Internal: distributes one unit of a reward token to to (ETHER from this contract; ERC20/721/1155 from this contract). + function _distributeReward(address to, uint256 rewardTokenId) internal { + LibItems.RewardToken memory rewardToken = tokenRewards[rewardTokenId]; + LibItems.Reward[] memory rewards = rewardToken.rewards; + + for (uint256 i = 0; i < rewards.length; i++) { + LibItems.Reward memory r = rewards[i]; + if (r.rewardType == LibItems.RewardType.ETHER) { + if (address(this).balance < r.rewardAmount || ethReservedTotal < r.rewardAmount) revert InsufficientBalance(); + ethReservedTotal -= r.rewardAmount; + (bool ok, ) = payable(to).call{ value: r.rewardAmount }(""); + if (!ok) revert TransferFailed(); + } else if (r.rewardType == LibItems.RewardType.ERC20) { + SafeERC20.safeTransfer(IERC20(r.rewardTokenAddress), to, r.rewardAmount); + reservedAmounts[r.rewardTokenAddress] -= r.rewardAmount; + } else if (r.rewardType == LibItems.RewardType.ERC721) { + uint256 currentIndex = erc721RewardCurrentIndex[rewardTokenId][i]; + uint256[] memory tokenIds = r.rewardTokenIds; + for (uint256 j = 0; j < r.rewardAmount; j++) { + if (currentIndex + j >= tokenIds.length) revert InsufficientBalance(); + uint256 nftId = tokenIds[currentIndex + j]; + isErc721Reserved[r.rewardTokenAddress][nftId] = false; + erc721TotalReserved[r.rewardTokenAddress]--; + IERC721(r.rewardTokenAddress).safeTransferFrom(address(this), to, nftId); + } + erc721RewardCurrentIndex[rewardTokenId][i] += r.rewardAmount; + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId] -= r.rewardAmount; + erc1155TotalReserved[r.rewardTokenAddress] -= r.rewardAmount; + IERC1155(r.rewardTokenAddress).safeTransferFrom(address(this), to, r.rewardTokenId, r.rewardAmount, ""); + } + } + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPER FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + function _processERC20Token( + address tokenAddress, + uint256 index, + uint256[] memory totalBalances, + uint256[] memory reservedBalances, + uint256[] memory availableBalances, + string[] memory symbols, + string[] memory names, + string[] memory types + ) private view { + uint256 totalBalance = IERC20(tokenAddress).balanceOf(address(this)); + uint256 reserved = reservedAmounts[tokenAddress]; + + totalBalances[index] = totalBalance; + reservedBalances[index] = reserved; + availableBalances[index] = totalBalance > reserved ? totalBalance - reserved : 0; + + try IERC20Metadata(tokenAddress).symbol() returns (string memory symbol) { + symbols[index] = symbol; + } catch { + symbols[index] = "UNKNOWN"; + } + + try IERC20Metadata(tokenAddress).name() returns (string memory name) { + names[index] = name; + } catch { + names[index] = "Unknown Token"; + } + + types[index] = "fa"; + } + + function _processERC721Token( + address tokenAddress, + uint256 index, + uint256[] memory totalBalances, + uint256[] memory reservedBalances, + uint256[] memory availableBalances, + string[] memory symbols, + string[] memory names, + string[] memory types + ) private view { + uint256 totalBalance = IERC721(tokenAddress).balanceOf(address(this)); + uint256 reserved = erc721TotalReserved[tokenAddress]; + + totalBalances[index] = totalBalance; + reservedBalances[index] = reserved; + availableBalances[index] = totalBalance > reserved ? totalBalance - reserved : 0; + + try IERC721Metadata(tokenAddress).symbol() returns (string memory symbol) { + symbols[index] = symbol; + } catch { + symbols[index] = "ERC721"; + } + + try IERC721Metadata(tokenAddress).name() returns (string memory name) { + names[index] = name; + } catch { + names[index] = "NFT Collection"; + } + + types[index] = "nft"; + } + + function _processERC1155Tokens( + uint256 erc1155Count, + uint256 startIndex, + address[] memory addresses, + uint256[] memory totalBalances, + uint256[] memory reservedBalances, + uint256[] memory availableBalances, + string[] memory symbols, + string[] memory names, + string[] memory types, + uint256[] memory tokenIds + ) private view returns (uint256) { + uint256[] memory ids = itemIds; + + address[] memory processedErc1155Addresses = new address[](erc1155Count); + uint256[] memory processedErc1155TokenIds = new uint256[](erc1155Count); + uint256 processedCount = 0; + uint256 currentIndex = startIndex; + + for (uint256 i = 0; i < ids.length; i++) { + LibItems.Reward[] memory rewardsList = tokenRewards[ids[i]].rewards; + + for (uint256 j = 0; j < rewardsList.length; j++) { + LibItems.Reward memory reward = rewardsList[j]; + + if (reward.rewardType != LibItems.RewardType.ERC1155) continue; + + address erc1155Address = reward.rewardTokenAddress; + uint256 erc1155TokenId = reward.rewardTokenId; + + bool alreadyAdded = false; + for (uint256 k = 0; k < processedCount; k++) { + if (processedErc1155Addresses[k] == erc1155Address && + processedErc1155TokenIds[k] == erc1155TokenId) { + alreadyAdded = true; + break; + } + } + + if (!alreadyAdded && currentIndex < addresses.length) { + processedErc1155Addresses[processedCount] = erc1155Address; + processedErc1155TokenIds[processedCount] = erc1155TokenId; + processedCount++; + + addresses[currentIndex] = erc1155Address; + tokenIds[currentIndex] = erc1155TokenId; + + uint256 balance = IERC1155(erc1155Address).balanceOf(address(this), erc1155TokenId); + uint256 reserved = erc1155ReservedAmounts[erc1155Address][erc1155TokenId]; + + totalBalances[currentIndex] = balance; + reservedBalances[currentIndex] = reserved; + availableBalances[currentIndex] = balance > reserved ? balance - reserved : 0; + + try IERC1155Metadata(erc1155Address).name() returns (string memory _name) { + names[currentIndex] = _name; + } catch { + names[currentIndex] = "ERC1155 Collection"; + } + + try IERC1155Metadata(erc1155Address).symbol() returns (string memory _symbol) { + symbols[currentIndex] = _symbol; + } catch { + symbols[currentIndex] = "ERC1155"; + } + + types[currentIndex] = "nft"; + + currentIndex++; + } + } + } + + return currentIndex; + } + + function _countUniqueErc1155TokenIds() private view returns (uint256) { + uint256[] memory ids = itemIds; + + // Safe upper bound: total ERC1155 reward entries (unique count cannot exceed this) + uint256 totalErc1155Entries = 0; + for (uint256 i = 0; i < ids.length; i++) { + LibItems.Reward[] memory rewardsList = tokenRewards[ids[i]].rewards; + for (uint256 j = 0; j < rewardsList.length; j++) { + if (rewardsList[j].rewardType == LibItems.RewardType.ERC1155) totalErc1155Entries++; + } + } + + address[] memory uniqueAddresses = new address[](totalErc1155Entries); + uint256[] memory uniqueTokenIds = new uint256[](totalErc1155Entries); + uint256 count = 0; + + for (uint256 i = 0; i < ids.length; i++) { + LibItems.Reward[] memory rewardsList = tokenRewards[ids[i]].rewards; + + for (uint256 j = 0; j < rewardsList.length; j++) { + if (rewardsList[j].rewardType == LibItems.RewardType.ERC1155) { + address erc1155Address = rewardsList[j].rewardTokenAddress; + uint256 erc1155TokenId = rewardsList[j].rewardTokenId; + + bool found = false; + for (uint256 k = 0; k < count; k++) { + if (uniqueAddresses[k] == erc1155Address && + uniqueTokenIds[k] == erc1155TokenId) { + found = true; + break; + } + } + + if (!found) { + uniqueAddresses[count] = erc1155Address; + uniqueTokenIds[count] = erc1155TokenId; + count++; + } + } + } + } + + return count; + } + + + /// @notice Accepts ETH sent to this server's treasury (e.g. for topping up unreserved ETH). + receive() external payable {} +} diff --git a/scripts/deployRewardsServer.ts b/scripts/deployRewardsServer.ts new file mode 100644 index 0000000..c38ba73 --- /dev/null +++ b/scripts/deployRewardsServer.ts @@ -0,0 +1,63 @@ +import { ethers, upgrades } from 'hardhat'; + +/** + * Deploy RewardsRouter (one router, many servers). + * + * 1. Deploy RewardsServer implementation + * 2. Deploy RewardsRouter (UUPS proxy), initialize(devWallet, serverImplementation) + * — Router deploys UpgradeableBeacon(implementation, devWallet) internally; devWallet owns the beacon. + * + * After this, an account with MANAGER_ROLE (granted to devWallet) can call router.deployServer(serverId, serverAdmin) to create a server. + * + * Usage: + * pnpm hardhat run scripts/deployRewardsManager.ts --network hardhat + * pnpm hardhat run scripts/deployRewardsManager.ts --network sepolia + */ + +async function main() { + const [deployer] = await ethers.getSigners(); + + console.log('========================================'); + console.log('RewardsRouter Deployment'); + console.log('========================================'); + console.log('Deployer:', deployer.address); + console.log('========================================\n'); + + const devWallet = deployer.address; + + // 1. Deploy RewardsServer implementation (router will create beacon pointing to this) + console.log('1. Deploying RewardsServer implementation...'); + const RewardsServer = await ethers.getContractFactory('RewardsServer'); + const rewardsServerImpl = await RewardsServer.deploy(); + await rewardsServerImpl.waitForDeployment(); + const rewardsServerImplAddress = await rewardsServerImpl.getAddress(); + console.log(' RewardsServer impl:', rewardsServerImplAddress); + + // 2. Deploy RewardsRouter (UUPS proxy); initialize deploys UpgradeableBeacon(impl, devWallet) + console.log('\n2. Deploying RewardsRouter (UUPS proxy)...'); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const router = await upgrades.deployProxy(RewardsRouter, [devWallet, rewardsServerImplAddress], { + kind: 'uups', + initializer: 'initialize', + }); + await router.waitForDeployment(); + const routerAddress = await router.getAddress(); + const routerImpl = await upgrades.erc1967.getImplementationAddress(routerAddress); + console.log(' RewardsRouter proxy:', routerAddress); + console.log(' RewardsRouter impl:', routerImpl); + console.log(' serverBeacon (owner: dev):', await router.serverBeacon()); + + console.log('\n========================================'); + console.log('Deployment complete.'); + console.log('========================================'); + console.log('RewardsRouter:', routerAddress); + console.log('To create a server: router.deployServer(serverId, serverAdmin)'); + console.log(' e.g. serverId = 1 (uint8), serverAdmin = address with SERVER_ADMIN_ROLE on the new server'); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/test/rewardsRouter.test.ts b/test/rewardsRouter.test.ts new file mode 100644 index 0000000..c7742df --- /dev/null +++ b/test/rewardsRouter.test.ts @@ -0,0 +1,424 @@ +import { expect } from 'chai'; +import { ethers, upgrades, network } from 'hardhat'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; + +/** + * RewardsRouter tests (router orchestration + view proxies). + * Security assumptions covered: (1) Claim signatures use per-user nonce for replay protection; no on-chain expiry. + * (2) Claim data encodes contractAddress and chainId so claims are bound to the correct server and chain. + * (3) Treasury is funded by direct transfers; only SERVER_ADMIN can withdraw. (4) Signature for one server cannot + * be replayed on another (contractAddress check in server.claim). + */ +describe('RewardsRouter', function () { + const SERVER_ID = 1; + + async function deployRewardsManagerFixture() { + const [devWallet, managerWallet, user1, user2] = await ethers.getSigners(); + + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const rewardsServerImpl = await RewardsServerImpl.deploy(); + await rewardsServerImpl.waitForDeployment(); + + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const manager = await upgrades.deployProxy( + RewardsRouter, + [devWallet.address, await rewardsServerImpl.getAddress()], + { kind: 'uups', initializer: 'initialize' } + ); + await manager.waitForDeployment(); + + const MANAGER_ROLE = await manager.MANAGER_ROLE(); + await manager.connect(devWallet).grantRole(MANAGER_ROLE, managerWallet.address); + + return { + manager, + devWallet, + managerWallet, + user1, + user2, + }; + } + + describe('initialize', function () { + it('reverts with AddressIsZero when devWallet is zero', async function () { + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const rewardsServerImpl = await RewardsServerImpl.deploy(); + await rewardsServerImpl.waitForDeployment(); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const routerImpl = await RewardsRouter.deploy(); + await routerImpl.waitForDeployment(); + await expect( + upgrades.deployProxy(RewardsRouter, [ethers.ZeroAddress, await rewardsServerImpl.getAddress()], { kind: 'uups', initializer: 'initialize' }) + ).to.be.revertedWithCustomError(routerImpl, 'AddressIsZero'); + }); + + it('reverts with AddressIsZero when serverImplementation is zero', async function () { + const [devWallet] = await ethers.getSigners(); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const routerImpl = await RewardsRouter.deploy(); + await routerImpl.waitForDeployment(); + await expect( + upgrades.deployProxy(RewardsRouter, [devWallet.address, ethers.ZeroAddress], { kind: 'uups', initializer: 'initialize' }) + ).to.be.revertedWithCustomError(routerImpl, 'AddressIsZero'); + }); + }); + + describe('setServerBeacon', function () { + it('reverts with AddressIsZero when beacon address is zero', async function () { + const { manager, devWallet } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.connect(devWallet).setServerBeacon(ethers.ZeroAddress)) + .to.be.revertedWithCustomError(manager, 'AddressIsZero'); + }); + + it('DEV_CONFIG_ROLE can update beacon by calling setServerBeacon again', async function () { + const { manager, devWallet } = await loadFixture(deployRewardsManagerFixture); + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const impl2 = await RewardsServerImpl.deploy(); + await impl2.waitForDeployment(); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const router2 = await upgrades.deployProxy( + RewardsRouter, + [devWallet.address, await impl2.getAddress()], + { kind: 'uups', initializer: 'initialize' } + ); + await router2.waitForDeployment(); + const beacon2Address = await router2.serverBeacon(); + await manager.connect(devWallet).setServerBeacon(beacon2Address); + expect(await manager.serverBeacon()).to.equal(beacon2Address); + }); + }); + + describe('deployServer', function () { + it('should deploy a server with RewardsServer', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + + const serverAddr = await manager.getServer(SERVER_ID); + expect(serverAddr).to.properAddress; + }); + + it('should revert when serverId already exists', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + await expect(manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address)) + .to.be.revertedWithCustomError(manager, 'ServerAlreadyExists'); + }); + + it('should revert when serverId is zero', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.connect(managerWallet).deployServer(0, devWallet.address)) + .to.be.revertedWithCustomError(manager, 'InvalidServerId'); + }); + + it('emits ServerDeployed with serverId and server address', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + const tx = await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + await tx.wait(); + const serverAddr = await manager.getServer(SERVER_ID); + await expect(tx).to.emit(manager, 'ServerDeployed').withArgs(SERVER_ID, serverAddr); + }); + + it('reverts with BeaconNotInitialized when serverBeacon is zero in storage', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + const proxyAddress = await manager.getAddress(); + const slot = '0x' + (1).toString(16).padStart(64, '0'); + await network.provider.send('hardhat_setStorageAt', [ + proxyAddress, + slot, + '0x' + '00'.repeat(32), + ]); + await expect(manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address)) + .to.be.revertedWithCustomError(manager, 'BeaconNotInitialized'); + }); + }); + + describe('getServer and view errors', function () { + it('getServer reverts with ServerDoesNotExist for non-existent serverId', async function () { + const { manager } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.getServer(2)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + }); + + it('view proxies revert with ServerDoesNotExist when server does not exist', async function () { + const { manager } = await loadFixture(deployRewardsManagerFixture); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const token = await MockERC20.deploy('M', 'M'); + await token.waitForDeployment(); + await expect(manager.getServerTreasuryBalances(2)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerAllItemIds(2)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerTokenRewards(2, 1)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerTreasuryBalance(2, await token.getAddress())).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerReservedAmount(2, await token.getAddress())).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerAvailableTreasuryBalance(2, await token.getAddress())).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerWhitelistedTokens(2)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.isServerWhitelistedToken(2, await token.getAddress())).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getTokenDetails(2, 1)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getRemainingSupply(2, 1)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + await expect(manager.getServerSigners(2)).to.be.revertedWithCustomError(manager, 'ServerDoesNotExist'); + }); + }); + + describe('decodeClaimData', function () { + it('decodes claim data correctly', async function () { + const { manager } = await loadFixture(deployRewardsManagerFixture); + const chainId = (await ethers.provider.getNetwork()).chainId; + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint8', 'uint256[]'], + ['0x0000000000000000000000000000000000000001', chainId, '0x0000000000000000000000000000000000000002', 5n, 1, [10, 20]] + ); + const decoded = await manager.decodeClaimData(data); + expect(decoded.contractAddress).to.equal('0x0000000000000000000000000000000000000001'); + expect(decoded.chainId).to.equal(chainId); + expect(decoded.beneficiary).to.equal('0x0000000000000000000000000000000000000002'); + expect(decoded.userNonce).to.equal(5n); + expect(decoded.serverId).to.equal(1); + expect(decoded.tokenIds).to.deep.equal([10n, 20n]); + }); + }); + + /** Build claim data and signature for claim(). Targets the given server; signer must be a server signer. */ + async function buildClaimDataAndSignature( + serverAddress: string, + serverId: number, + signer: Awaited>[0], + beneficiary: string, + tokenIds: number[], + userNonce: number + ) { + const chainId = (await ethers.provider.getNetwork()).chainId; + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint8', 'uint256[]'], + [serverAddress, chainId, beneficiary, BigInt(userNonce), serverId, tokenIds] + ); + const messageHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'uint8', 'address', 'uint256', 'uint256[]'], + [serverAddress, chainId, serverId, beneficiary, BigInt(userNonce), tokenIds] + ) + ); + const signature = await signer.signMessage(ethers.getBytes(messageHash)); + return { data, signature }; + } + + + describe('access control', function () { + it('non-MANAGER cannot call router pause', async function () { + const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.connect(user1).pause()).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + }); + + it('only MANAGER_ROLE can call deployServer', async function () { + const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.connect(user1).deployServer(2, user1.address)) + .to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + }); + }); + + describe('router view proxies and claim', function () { + it('router getServerTreasuryBalances and getTokenDetails mirror server state', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.managerWallet.address); + + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('Mock', 'M'); + await mockERC20.waitForDeployment(); + await mockERC20.mint(base.managerWallet.address, ethers.parseEther('10000')); + + await server.connect(base.managerWallet).whitelistToken(await mockERC20.getAddress(), 1); // ERC20 + await mockERC20.connect(base.managerWallet).transfer(serverAddr, ethers.parseEther('1000')); + + const { manager, managerWallet } = base; + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const details = await manager.getTokenDetails(SERVER_ID, tokenId); + expect(details.maxSupply).to.equal(10); + expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(10); + const [addresses, totalBalances] = await manager.getServerTreasuryBalances(SERVER_ID); + expect(addresses).to.include(await mockERC20.getAddress()); + expect(await manager.isTokenExist(SERVER_ID, tokenId)).to.be.true; + }); + + it('router pause blocks claim even when server is unpaused', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.managerWallet.address); + await server.connect(base.managerWallet).setSigner(base.managerWallet.address, true); + + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('Mock', 'M'); + await mockERC20.waitForDeployment(); + await mockERC20.mint(base.managerWallet.address, ethers.parseEther('10000')); + + await server.connect(base.managerWallet).whitelistToken(await mockERC20.getAddress(), 1); // ERC20 + await mockERC20.connect(base.managerWallet).transfer(serverAddr, ethers.parseEther('1000')); + + const { manager, managerWallet, user1 } = base; + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 0); + await manager.connect(managerWallet).pause(); + await expect(manager.connect(user1).claim(data, signature)).to.be.revertedWithCustomError( + manager, + 'EnforcedPause' + ); + await manager.connect(managerWallet).unpause(); + await manager.connect(user1).claim(data, signature); + expect(await mockERC20.balanceOf(user1.address)).to.equal(ethers.parseEther('10')); + }); + + it('emits RewardClaimed on successful claim', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.managerWallet.address); + await server.connect(base.managerWallet).setSigner(base.managerWallet.address, true); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('Mock', 'M'); + await mockERC20.waitForDeployment(); + await mockERC20.mint(base.managerWallet.address, ethers.parseEther('10000')); + await server.connect(base.managerWallet).whitelistToken(await mockERC20.getAddress(), 1); + await mockERC20.connect(base.managerWallet).transfer(serverAddr, ethers.parseEther('1000')); + const tokenId = 1; + await server.connect(base.managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'u', maxSupply: 1, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + const { data, signature } = await buildClaimDataAndSignature(serverAddr, SERVER_ID, base.managerWallet, base.user1.address, [tokenId], 0); + await expect(base.manager.connect(base.user1).claim(data, signature)) + .to.emit(base.manager, 'RewardClaimed') + .withArgs(SERVER_ID, base.user1.address, 0n, [1n]); + }); + + it('MANAGER can unpause', async function () { + const { manager, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).pause(); + expect(await manager.paused()).to.be.true; + await manager.connect(managerWallet).unpause(); + expect(await manager.paused()).to.be.false; + }); + + it('router receive() accepts ETH', async function () { + const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); + const amount = ethers.parseEther('0.5'); + await user1.sendTransaction({ to: await manager.getAddress(), value: amount }); + expect(await ethers.provider.getBalance(await manager.getAddress())).to.equal(amount); + }); + + it('router view proxies: getServerAllItemIds, getServerTokenRewards, reserved, available, whitelist', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.managerWallet.address); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('Mock', 'M'); + await mockERC20.waitForDeployment(); + await mockERC20.mint(base.managerWallet.address, ethers.parseEther('10000')); + await server.connect(base.managerWallet).whitelistToken(await mockERC20.getAddress(), 1); + await mockERC20.connect(base.managerWallet).transfer(serverAddr, ethers.parseEther('500')); + const tokenId = 7; + await server.connect(base.managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'https://x.com/7', maxSupply: 5, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + const itemIds = await base.manager.getServerAllItemIds(SERVER_ID); + expect(itemIds).to.deep.equal([7n]); + const rewards = await base.manager.getServerTokenRewards(SERVER_ID, tokenId); + expect(rewards.length).to.equal(1); + expect(rewards[0].rewardAmount).to.equal(ethers.parseEther('10')); + expect(await base.manager.getServerReservedAmount(SERVER_ID, await mockERC20.getAddress())).to.equal(ethers.parseEther('50')); + expect(await base.manager.getServerAvailableTreasuryBalance(SERVER_ID, await mockERC20.getAddress())).to.equal(ethers.parseEther('450')); + const whitelist = await base.manager.getServerWhitelistedTokens(SERVER_ID); + expect(whitelist).to.include(await mockERC20.getAddress()); + expect(await base.manager.isServerWhitelistedToken(SERVER_ID, await mockERC20.getAddress())).to.be.true; + expect(await base.manager.isServerWhitelistedToken(SERVER_ID, base.user1.address)).to.be.false; + }); + }); + + describe('admin proxy and wrapper functions', function () { + describe('getServerSigners', function () { + it('returns empty when no signers added', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(0); + }); + + it('returns signers after setSigner', async function () { + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); + let list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(0); + await server.connect(user1).setSigner(user2.address, true); + list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(1); + expect(list[0]).to.equal(user2.address); + await server.connect(user1).setSigner(user1.address, true); + list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(2); + expect(list).to.include(user2.address); + expect(list).to.include(user1.address); + await server.connect(user1).setSigner(user2.address, false); + list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(1); + expect(list[0]).to.equal(user1.address); + }); + }); + + describe('claim ETH rewards', function () { + it('sends ETH to beneficiary for ETHER rewards on claim', async function () { + const { manager, devWallet, managerWallet, user1 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const serverContract = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await serverContract.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, managerWallet.address); + await serverContract.connect(managerWallet).setSigner(managerWallet.address, true); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/eth', + maxSupply: 2, + rewards: [{ rewardType: 0, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }], + }; + await serverContract.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: ethers.parseEther('2') }); + const { data, signature } = await buildClaimDataAndSignature(serverAddr, SERVER_ID, managerWallet, user1.address, [tokenId], 0); + const beforeBalance = await ethers.provider.getBalance(user1.address); + const tx = await manager.connect(user1).claim(data, signature); + const receipt = await tx.wait(); + const gasCost = receipt!.gasUsed * receipt!.gasPrice; + const afterBalance = await ethers.provider.getBalance(user1.address); + expect(afterBalance - (beforeBalance - gasCost)).to.equal(ethers.parseEther('1')); + }); + }); + }); +}); diff --git a/test/rewardsServer.test.ts b/test/rewardsServer.test.ts new file mode 100644 index 0000000..4f96538 --- /dev/null +++ b/test/rewardsServer.test.ts @@ -0,0 +1,1438 @@ +import { expect } from 'chai'; +import { ethers, upgrades } from 'hardhat'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; + +/** + * RewardsServer tests (server treasury, rewards, signatures, and access control). + */ +describe('RewardsServer', function () { + const SERVER_ID = 1; + + async function deployRewardsManagerFixture() { + const [devWallet, managerWallet, user1, user2] = await ethers.getSigners(); + + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const rewardsServerImpl = await RewardsServerImpl.deploy(); + await rewardsServerImpl.waitForDeployment(); + + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const manager = await upgrades.deployProxy( + RewardsRouter, + [devWallet.address, await rewardsServerImpl.getAddress()], + { kind: 'uups', initializer: 'initialize' } + ); + await manager.waitForDeployment(); + + const MANAGER_ROLE = await manager.MANAGER_ROLE(); + await manager.connect(devWallet).grantRole(MANAGER_ROLE, managerWallet.address); + + return { + manager, + devWallet, + managerWallet, + user1, + user2, + }; + } + + async function deployWithServerAndTokenFixture() { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.managerWallet.address); + await server.connect(base.managerWallet).setSigner(base.managerWallet.address, true); + + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('Mock', 'M'); + await mockERC20.waitForDeployment(); + await mockERC20.mint(base.managerWallet.address, ethers.parseEther('10000')); + + await server.connect(base.managerWallet).whitelistToken(await mockERC20.getAddress(), 1); // ERC20 + await mockERC20.connect(base.managerWallet).transfer(serverAddr, ethers.parseEther('1000')); + + return { ...base, server, mockERC20 }; + } + + /** Build claim data and signature for RewardsServer.claim(). Targets the given server; signer must be a server signer. */ + async function buildClaimDataAndSignature( + serverAddress: string, + serverId: number, + signer: Awaited>[0], + beneficiary: string, + tokenIds: number[], + userNonce: number + ) { + const chainId = (await ethers.provider.getNetwork()).chainId; + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint8', 'uint256[]'], + [serverAddress, chainId, beneficiary, BigInt(userNonce), serverId, tokenIds] + ); + const messageHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'uint8', 'address', 'uint256', 'uint256[]'], + [serverAddress, chainId, serverId, beneficiary, BigInt(userNonce), tokenIds] + ) + ); + const signature = await signer.signMessage(ethers.getBytes(messageHash)); + return { data, signature }; + } + + describe('server admin and signers', function () { + it('server admin can set signer', async function () { + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); + + await server.connect(user1).setSigner(user2.address, true); + expect(await manager.getServerSigners(SERVER_ID)).to.include(user2.address); + }); + + it('non-admin cannot set signer', async function () { + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); + await expect(server.connect(user2).setSigner(user2.address, true)) + .to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); + }); + + it('reverts with AddressIsZero when setting zero address as signer', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + await expect(server.connect(devWallet).setSigner(ethers.ZeroAddress, true)) + .to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('reverts with SignerAlreadySet when setting same active state', async function () { + const { manager, devWallet, managerWallet, user1 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); + await server.connect(user1).setSigner(user1.address, true); + await expect(server.connect(user1).setSigner(user1.address, true)) + .to.be.revertedWithCustomError(server, 'SignerAlreadySet'); + }); + }); + + describe('treasury and reward flow', function () { + it('should whitelist token and deposit to server treasury', async function () { + const { manager, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const balance = await manager.getServerTreasuryBalance( + SERVER_ID, + await mockERC20.getAddress() + ); + expect(balance).to.equal(ethers.parseEther('1000')); + }); + + it('should create reward token and claim with signature', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { + rewardType: 1, // ERC20 + rewardAmount: ethers.parseEther('10'), + rewardTokenAddress: await mockERC20.getAddress(), + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + + expect(await manager.isTokenExist(SERVER_ID, tokenId)).to.be.true; + + const { data, signature } = await buildClaimDataAndSignature( + await server.getAddress(), + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 0 + ); + const before = await mockERC20.balanceOf(user1.address); + await manager.connect(user1).claim(data, signature); + const after_ = await mockERC20.balanceOf(user1.address); + expect(after_ - before).to.equal(ethers.parseEther('10')); + }); + + describe('ETHER reward flow', function () { + it('creates ETHER reward token, claim sends ETH to beneficiary', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 2; + const rewardAmount = ethers.parseEther('0.5'); + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/eth', + maxSupply: 2, + rewards: [ + { + rewardType: 0, // ETHER + rewardAmount, + rewardTokenAddress: ethers.ZeroAddress, + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + const ethRequired = rewardAmount * 2n; // maxSupply 2 + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: ethRequired }); + const { data, signature } = await buildClaimDataAndSignature( + await server.getAddress(), + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 0 + ); + const before = await ethers.provider.getBalance(user1.address); + const tx = await manager.connect(user1).claim(data, signature); + const receipt = await tx.wait(); + const gasCost = receipt!.gasUsed * receipt!.gasPrice; + const after_ = await ethers.provider.getBalance(user1.address); + expect(after_ - (before - gasCost)).to.equal(rewardAmount); + }); + + it('SERVER_ADMIN can withdraw unreserved ETHER from server treasury', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 3; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/eth2', + maxSupply: 1, + rewards: [ + { + rewardType: 0, + rewardAmount: ethers.parseEther('1'), + rewardTokenAddress: ethers.ZeroAddress, + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: ethers.parseEther('1') }); + const { data, signature } = await buildClaimDataAndSignature( + await server.getAddress(), + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 0 + ); + await manager.connect(user1).claim(data, signature); + const extraEth = ethers.parseEther('0.3'); + const serverAddr = await manager.getServer(SERVER_ID); + await managerWallet.sendTransaction({ + to: serverAddr, + value: extraEth, + }); + const before = await ethers.provider.getBalance(user1.address); + await server.connect(managerWallet).withdrawAssets( + 0, // ETHER + user1.address, + ethers.ZeroAddress, + [], + [extraEth] + ); + const after_ = await ethers.provider.getBalance(user1.address); + expect(after_ - before).to.equal(extraEth); + }); + }); + + it('should allow multiple claims with different nonces', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { + rewardType: 1, + rewardAmount: ethers.parseEther('10'), + rewardTokenAddress: await mockERC20.getAddress(), + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( + await server.getAddress(), + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 0 + ); + const { data: data2, signature: sig2 } = await buildClaimDataAndSignature( + await server.getAddress(), + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 1 + ); + await manager.connect(user1).claim(data1, sig1); + await manager.connect(user1).claim(data2, sig2); + + expect(await mockERC20.balanceOf(user1.address)).to.equal(ethers.parseEther('20')); + }); + + it('allows relayer to submit claim: rewards go to beneficiary in data', async function () { + const { manager, server, managerWallet, user1, user2, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { + rewardType: 1, + rewardAmount: ethers.parseEther('10'), + rewardTokenAddress: await mockERC20.getAddress(), + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data, signature } = await buildClaimDataAndSignature( + await server.getAddress(), + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 0 + ); + const before = await mockERC20.balanceOf(user1.address); + await manager.connect(user2).claim(data, signature); + const after_ = await mockERC20.balanceOf(user1.address); + expect(after_ - before).to.equal(ethers.parseEther('10')); + }); + + describe('ERC721 reward flow', function () { + it('creates ERC721 reward token, claim sends NFT to beneficiary and advances index', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const mockERC721 = await MockERC721.deploy(); + await mockERC721.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await mockERC721.getAddress(), 2); // ERC721 + for (let i = 0; i < 3; i++) { + await mockERC721.mint(managerWallet.address); + } + await mockERC721.connect(managerWallet).setApprovalForAll(serverAddr, true); + await mockERC721.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 0); + await mockERC721.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 1); + await mockERC721.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 2); + const tokenId = 10; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/nft', + maxSupply: 3, + rewards: [ + { + rewardType: 2, // ERC721 + rewardAmount: 1, + rewardTokenAddress: await mockERC721.getAddress(), + rewardTokenIds: [0, 1, 2], + rewardTokenId: 0, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data: data0, signature: sig0 } = await buildClaimDataAndSignature( + serverAddr, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 0 + ); + await manager.connect(user1).claim(data0, sig0); + expect(await mockERC721.ownerOf(0)).to.equal(user1.address); + const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( + serverAddr, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + 1 + ); + await manager.connect(user1).claim(data1, sig1); + expect(await mockERC721.ownerOf(1)).to.equal(user1.address); + }); + }); + + describe('ERC1155 reward flow', function () { + it('creates ERC1155 reward token, claim sends tokens to beneficiary', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const mockERC1155 = await MockERC1155.deploy(); + await mockERC1155.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await mockERC1155.getAddress(), 3); // ERC1155 + const erc1155TokenId = 42; + const amount = 100n; + await mockERC1155.mint(managerWallet.address, erc1155TokenId, amount, '0x'); + await mockERC1155.connect(managerWallet).setApprovalForAll(serverAddr, true); + await mockERC1155 + .connect(managerWallet) + .safeTransferFrom(managerWallet.address, serverAddr, erc1155TokenId, amount, '0x'); + const rewardTokenId = 20; + const rewardToken = { + tokenId: rewardTokenId, + tokenUri: 'https://example.com/1155', + maxSupply: 5, + rewards: [ + { + rewardType: 3, // ERC1155 + rewardAmount: 10, + rewardTokenAddress: await mockERC1155.getAddress(), + rewardTokenIds: [], + rewardTokenId: erc1155TokenId, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data, signature } = await buildClaimDataAndSignature( + serverAddr, + SERVER_ID, + managerWallet, + user1.address, + [rewardTokenId], + 0 + ); + await manager.connect(user1).claim(data, signature); + expect(await mockERC1155.balanceOf(user1.address, erc1155TokenId)).to.equal(10); + }); + }); + }); + + describe('security assumptions', function () { + it('only SERVER_ADMIN can withdraw from treasury', async function () { + const { manager, server, managerWallet, user1, user2, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const extra = ethers.parseEther('100'); + await mockERC20.mint(user2.address, extra); + await mockERC20.connect(user2).transfer(await server.getAddress(), extra); + expect(await manager.getServerTreasuryBalance(SERVER_ID, await mockERC20.getAddress())).to.be.gte(ethers.parseEther('1100')); + await expect( + server.connect(user2).withdrawAssets(1, user2.address, await mockERC20.getAddress(), [], []) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); + }); + }); + + describe('access control', function () { + it('non-server-admin cannot call whitelistToken', async function () { + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('M', 'M'); + await mockERC20.waitForDeployment(); + await expect( + server.connect(user2).whitelistToken(await mockERC20.getAddress(), 1) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); + }); + + it('non-server-admin cannot call createTokenAndReserveRewards', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.user1.address); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('M', 'M'); + await mockERC20.waitForDeployment(); + await server.connect(base.user1).whitelistToken(await mockERC20.getAddress(), 1); + const rewardToken = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [ + { + rewardType: 1, + rewardAmount: 1, + rewardTokenAddress: await mockERC20.getAddress(), + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + await expect( + server.connect(base.user2).createTokenAndReserveRewards(rewardToken, { value: 0 }) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); + }); + + it('non-server-admin cannot call withdrawAssets', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.user1.address); + await expect( + server.connect(base.user2).withdrawAssets(1, base.user2.address, ethers.ZeroAddress, [], []) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); + }); + + it('reverts with AddressIsZero when whitelisting zero address', async function () { + const { manager, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, managerWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + await expect(server.connect(managerWallet).whitelistToken(ethers.ZeroAddress, 1)) + .to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('reverts with TokenAlreadyWhitelisted when whitelisting same token again', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + await expect(server.connect(managerWallet).whitelistToken(await mockERC20.getAddress(), 1)) + .to.be.revertedWithCustomError(server, 'TokenAlreadyWhitelisted'); + }); + }); + + describe('removeTokenFromWhitelist and withdraw paths', function () { + it('SERVER_ADMIN can remove token from whitelist when no reserves', async function () { + const { manager, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, managerWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const serverContract = await ethers.getContractAt('RewardsServer', serverAddr); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('Extra', 'E'); + await mockERC20.waitForDeployment(); + await mockERC20.mint(managerWallet.address, ethers.parseEther('100')); + await serverContract.connect(managerWallet).whitelistToken(await mockERC20.getAddress(), 1); + expect(await manager.isServerWhitelistedToken(SERVER_ID, await mockERC20.getAddress())).to.be.true; + await serverContract.connect(managerWallet).removeTokenFromWhitelist(await mockERC20.getAddress()); + expect(await manager.isServerWhitelistedToken(SERVER_ID, await mockERC20.getAddress())).to.be.false; + }); + + it('withdrawAssets ERC20: withdraws unreserved ERC20 to recipient', async function () { + const { server, managerWallet, mockERC20, user1 } = await loadFixture(deployWithServerAndTokenFixture); + await mockERC20.connect(managerWallet).transfer(await server.getAddress(), ethers.parseEther('200')); + const before = await mockERC20.balanceOf(user1.address); + await server.connect(managerWallet).withdrawAssets(1, user1.address, await mockERC20.getAddress(), [], []); + const after_ = await mockERC20.balanceOf(user1.address); + expect(after_ - before).to.equal(ethers.parseEther('1200')); + }); + + it('withdrawAssets reverts InvalidLength for ERC1155 when tokenIds and amounts length mismatch', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 3); + await expect( + server.connect(managerWallet).withdrawAssets(3, user1.address, await m.getAddress(), [1], [10, 20]) + ).to.be.revertedWithCustomError(server, 'InvalidLength'); + }); + + it('withdrawUnreservedTreasury reverts TokenNotWhitelisted for non-whitelisted token', async function () { + const { manager, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, managerWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('X', 'X'); + await mockERC20.waitForDeployment(); + await expect( + server.connect(managerWallet).withdrawUnreservedTreasury(await mockERC20.getAddress(), managerWallet.address) + ).to.be.revertedWithCustomError(server, 'TokenNotWhitelisted'); + }); + + it('withdrawAssets reverts AddressIsZero when to is zero', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + await expect( + server.connect(managerWallet).withdrawAssets(0, ethers.ZeroAddress, ethers.ZeroAddress, [], [ethers.parseEther('1')]) + ).to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('withdrawAssets ETHER reverts InvalidInput when amounts is empty', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + await expect( + server.connect(managerWallet).withdrawAssets(0, user1.address, ethers.ZeroAddress, [], []) + ).to.be.revertedWithCustomError(server, 'InvalidInput'); + }); + + it('withdrawUnreservedTreasury reverts AddressIsZero when _to is zero', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + await expect( + server.connect(managerWallet).withdrawUnreservedTreasury(await mockERC20.getAddress(), ethers.ZeroAddress) + ).to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('withdrawUnreservedTreasury reverts InsufficientBalance when balance <= reserved', async function () { + const { server, managerWallet, mockERC20, user1 } = await loadFixture(deployWithServerAndTokenFixture); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 1, tokenUri: 'u', maxSupply: 1, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1000'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + await expect( + server.connect(managerWallet).withdrawUnreservedTreasury(await mockERC20.getAddress(), user1.address) + ).to.be.revertedWithCustomError(server, 'InsufficientBalance'); + }); + + it('withdrawERC721UnreservedTreasury reverts AddressIsZero when _to is zero', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + await expect( + server.connect(managerWallet).withdrawERC721UnreservedTreasury(await m.getAddress(), ethers.ZeroAddress, 0) + ).to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('withdrawERC721UnreservedTreasury reverts TokenNotWhitelisted', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + await expect( + server.connect(managerWallet).withdrawERC721UnreservedTreasury(await m.getAddress(), user1.address, 0) + ).to.be.revertedWithCustomError(server, 'TokenNotWhitelisted'); + }); + + it('withdrawERC721UnreservedTreasury reverts InsufficientTreasuryBalance when tokenId is reserved', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + await m.mint(managerWallet.address); + await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 0); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 30, tokenUri: 'nft', maxSupply: 1, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }] }, + { value: 0 } + ); + await expect( + server.connect(managerWallet).withdrawERC721UnreservedTreasury(await m.getAddress(), user1.address, 0) + ).to.be.revertedWithCustomError(server, 'InsufficientTreasuryBalance'); + }); + + it('withdrawERC721UnreservedTreasury success after claim frees NFT', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + await m.mint(managerWallet.address); + await m.mint(managerWallet.address); + await m.connect(managerWallet).setApprovalForAll(serverAddr, true); + await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 0); + await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 1); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 30, tokenUri: 'nft', maxSupply: 1, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }] }, + { value: 0 } + ); + const { data, signature } = await buildClaimDataAndSignature(serverAddr, SERVER_ID, managerWallet, user1.address, [30], 0); + await manager.connect(user1).claim(data, signature); + expect(await m.ownerOf(0)).to.equal(user1.address); + await server.connect(managerWallet).withdrawERC721UnreservedTreasury(await m.getAddress(), user1.address, 1); + expect(await m.ownerOf(1)).to.equal(user1.address); + }); + + it('withdrawAssets ERC721 withdraws unreserved NFT via withdrawAssets', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + for (let i = 0; i < 3; i++) await m.mint(managerWallet.address); + await m.connect(managerWallet).setApprovalForAll(serverAddr, true); + for (let i = 0; i < 3; i++) await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, i); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 40, tokenUri: 'nft', maxSupply: 1, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }] }, + { value: 0 } + ); + const { data, signature } = await buildClaimDataAndSignature(serverAddr, SERVER_ID, managerWallet, user1.address, [40], 0); + await manager.connect(user1).claim(data, signature); + await server.connect(managerWallet).withdrawAssets(2, user1.address, await m.getAddress(), [1, 2], []); + expect(await m.ownerOf(1)).to.equal(user1.address); + expect(await m.ownerOf(2)).to.equal(user1.address); + }); + + it('withdrawERC1155UnreservedTreasury reverts AddressIsZero when _to is zero', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await expect( + server.connect(managerWallet).withdrawERC1155UnreservedTreasury(await m.getAddress(), ethers.ZeroAddress, 0, 1) + ).to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('withdrawERC1155UnreservedTreasury reverts TokenNotWhitelisted', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await expect( + server.connect(managerWallet).withdrawERC1155UnreservedTreasury(await m.getAddress(), user1.address, 1, 1) + ).to.be.revertedWithCustomError(server, 'TokenNotWhitelisted'); + }); + + it('withdrawERC1155UnreservedTreasury reverts InsufficientBalance when balance <= reserved or amount > available', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 3); + await m.mint(managerWallet.address, 1, 50, '0x'); + await m.connect(managerWallet).setApprovalForAll(await server.getAddress(), true); + await m.connect(managerWallet).safeTransferFrom(managerWallet.address, await server.getAddress(), 1, 50, '0x'); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 60, tokenUri: '1155', maxSupply: 2, rewards: [{ rewardType: 3, rewardAmount: 20, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [], rewardTokenId: 1 }] }, + { value: 0 } + ); + await expect( + server.connect(managerWallet).withdrawERC1155UnreservedTreasury(await m.getAddress(), user1.address, 1, 20) + ).to.be.revertedWithCustomError(server, 'InsufficientBalance'); + await expect( + server.connect(managerWallet).withdrawERC1155UnreservedTreasury(await m.getAddress(), user1.address, 1, 11) + ).to.be.revertedWithCustomError(server, 'InsufficientBalance'); + }); + + it('withdrawERC1155UnreservedTreasury success withdraws unreserved amount', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 3); + await m.mint(managerWallet.address, 1, 100, '0x'); + await m.connect(managerWallet).setApprovalForAll(await server.getAddress(), true); + await m.connect(managerWallet).safeTransferFrom(managerWallet.address, await server.getAddress(), 1, 100, '0x'); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 61, tokenUri: '1155', maxSupply: 2, rewards: [{ rewardType: 3, rewardAmount: 20, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [], rewardTokenId: 1 }] }, + { value: 0 } + ); + const before = await m.balanceOf(user1.address, 1); + await server.connect(managerWallet).withdrawERC1155UnreservedTreasury(await m.getAddress(), user1.address, 1, 60); + expect(await m.balanceOf(user1.address, 1) - before).to.equal(60); + }); + + it('withdrawAssets ERC1155 success', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 3); + await m.mint(managerWallet.address, 1, 100, '0x'); + await m.connect(managerWallet).setApprovalForAll(await server.getAddress(), true); + await m.connect(managerWallet).safeTransferFrom(managerWallet.address, await server.getAddress(), 1, 100, '0x'); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 62, tokenUri: '1155', maxSupply: 1, rewards: [{ rewardType: 3, rewardAmount: 10, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [], rewardTokenId: 1 }] }, + { value: 0 } + ); + await server.connect(managerWallet).withdrawAssets(3, user1.address, await m.getAddress(), [1], [90]); + expect(await m.balanceOf(user1.address, 1)).to.equal(90); + }); + + it('withdrawEtherUnreservedTreasury reverts AddressIsZero', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + await expect( + server.connect(managerWallet).withdrawEtherUnreservedTreasury(ethers.ZeroAddress, ethers.parseEther('1')) + ).to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('withdrawEtherUnreservedTreasury reverts InsufficientBalance when amount > available', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 1, tokenUri: 'eth', maxSupply: 1, rewards: [{ rewardType: 0, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: ethers.parseEther('1') } + ); + await expect( + server.connect(managerWallet).withdrawEtherUnreservedTreasury(user1.address, ethers.parseEther('0.5')) + ).to.be.revertedWithCustomError(server, 'InsufficientBalance'); + }); + }); + + describe('createTokenAndReserveRewards validation', function () { + it('reverts with InvalidAmount when maxSupply is zero', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 0, + rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidAmount'); + }); + + it('reverts with InvalidInput when tokenUri empty', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: '', + maxSupply: 1, + rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidInput'); + }); + + it('reverts with DupTokenId when tokenId already exists', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 }); + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'DupTokenId'); + }); + + it('reverts with TokenNotWhitelisted when reward token not whitelisted', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.managerWallet.address); + const serverAddr = await base.manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('M', 'M'); + await mockERC20.waitForDeployment(); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(base.managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'TokenNotWhitelisted'); + }); + + it('reverts with InvalidInput when tokenId is zero', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 0, + tokenUri: 'u', + maxSupply: 1, + rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidInput'); + }); + + it('reverts with InvalidInput when rewards array is empty', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidInput'); + }); + + it('reverts with AddressIsZero when non-ETHER reward has zero token address', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'AddressIsZero'); + }); + + it('reverts with InvalidInput when ERC721 rewardTokenIds length mismatch', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 2, + rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidInput'); + }); + + it('reverts with InvalidAmount when ERC20 rewardAmount is zero', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [{ rewardType: 1, rewardAmount: 0n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidAmount'); + }); + + it('reverts with InsufficientBalance when msg.value less than ETH required', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'eth', + maxSupply: 2, + rewards: [{ rewardType: 0, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: ethers.parseEther('0.5') })) + .to.be.revertedWithCustomError(server, 'InsufficientBalance'); + }); + + it('reverts with InsufficientTreasuryBalance when ERC20 balance too low', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 10, + rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('101'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InsufficientTreasuryBalance'); + }); + + it('reverts with InsufficientTreasuryBalance when ERC721 NFT not owned by server', async function () { + const { server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + await m.mint(user1.address); + const token = { + tokenId: 1, + tokenUri: 'u', + maxSupply: 1, + rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InsufficientTreasuryBalance'); + }); + + it('reverts with InsufficientTreasuryBalance when ERC721 tokenId already reserved', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + await m.mint(managerWallet.address); + await m.mint(managerWallet.address); + await m.connect(managerWallet).setApprovalForAll(serverAddr, true); + await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 0); + await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 1); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 10, tokenUri: 'nft', maxSupply: 1, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }] }, + { value: 0 } + ); + const token = { + tokenId: 11, + tokenUri: 'u2', + maxSupply: 1, + rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0], rewardTokenId: 0 }], + }; + await expect(server.connect(managerWallet).createTokenAndReserveRewards(token, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InsufficientTreasuryBalance'); + }); + }); + + describe('signers and supply helpers', function () { + describe('setSigner', function () { + it('setSigner(true) enables signer, setSigner(false) disables', async function () { + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); + const listBefore = await manager.getServerSigners(SERVER_ID); + expect(listBefore).to.not.include(user2.address); + await server.connect(user1).setSigner(user2.address, true); + expect((await manager.getServerSigners(SERVER_ID))).to.include(user2.address); + await server.connect(user1).setSigner(user2.address, false); + expect((await manager.getServerSigners(SERVER_ID))).to.not.include(user2.address); + }); + }); + + describe('increaseRewardSupply and removeTokenFromWhitelist', function () { + it('SERVER_ADMIN can increase reward supply and reserves additional tokens', async function () { + const { manager, server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 5, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(5); + await mockERC20.connect(managerWallet).transfer(await server.getAddress(), ethers.parseEther('50')); + await server.connect(managerWallet).increaseRewardSupply(tokenId, 5, { value: 0 }); + expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(10); + const details = await manager.getTokenDetails(SERVER_ID, tokenId); + expect(details.maxSupply).to.equal(10); + }); + + it('removeTokenFromWhitelist reverts when token has reserves', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const rewardToken = { + tokenId: 99, + tokenUri: 'https://example.com/r', + maxSupply: 2, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + await expect(server.connect(managerWallet).removeTokenFromWhitelist(await mockERC20.getAddress())) + .to.be.revertedWithCustomError(server, 'TokenHasReserves'); + }); + + it('removeTokenFromWhitelist reverts TokenNotWhitelisted when token not in whitelist', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const other = await MockERC20.deploy('O', 'O'); + await other.waitForDeployment(); + await expect(server.connect(managerWallet).removeTokenFromWhitelist(await other.getAddress())) + .to.be.revertedWithCustomError(server, 'TokenNotWhitelisted'); + }); + }); + }); + + describe('claim replay and signature validation', function () { + it('reverts with NonceAlreadyUsed when same beneficiary and nonce used twice', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 0); + await manager.connect(user1).claim(data, signature); + await expect(manager.connect(user1).claim(data, signature)).to.be.revertedWithCustomError( + server, + 'NonceAlreadyUsed' + ); + }); + + it('reverts with InvalidSignature when signer not whitelisted', async function () { + const { manager, server, managerWallet, user1, user2, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, user2, user1.address, [tokenId], 0); + await expect(manager.connect(user1).claim(data, signature)).to.be.revertedWithCustomError( + server, + 'InvalidSignature' + ); + }); + + it('signature for one server cannot be replayed on another server', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const { signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 0); + await manager.connect(managerWallet).deployServer(2, managerWallet.address); + const server2Addr = await manager.getServer(2); + const server2 = await ethers.getContractAt('RewardsServer', server2Addr); + const chainId = (await ethers.provider.getNetwork()).chainId; + const dataForServer2 = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint8', 'uint256[]'], + [server2Addr, chainId, user1.address, 0n, 2, [tokenId]] + ); + await expect(manager.connect(user1).claim(dataForServer2, signature)).to.be.revertedWithCustomError( + server2, + 'InvalidSignature' + ); + }); + + it('reverts with InvalidInput when claim data has wrong contract address', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + const chainId = (await ethers.provider.getNetwork()).chainId; + const wrongAddress = managerWallet.address; + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint8', 'uint256[]'], + [wrongAddress, chainId, user1.address, 0n, SERVER_ID, [tokenId]] + ); + const messageHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'uint8', 'address', 'uint256', 'uint256[]'], + [await server.getAddress(), chainId, SERVER_ID, user1.address, 0n, [tokenId]] + ) + ); + const signature = await managerWallet.signMessage(ethers.getBytes(messageHash)); + await expect(manager.connect(user1).claim(data, signature)).to.be.revertedWithCustomError( + server, + 'InvalidInput' + ); + }); + }); + + describe('reduceRewardSupply', function () { + it('SERVER_ADMIN can reduce reward supply and event is emitted', async function () { + const { manager, server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { + rewardType: 1, + rewardAmount: ethers.parseEther('10'), + rewardTokenAddress: await mockERC20.getAddress(), + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + let details = await manager.getTokenDetails(SERVER_ID, tokenId); + expect(details.maxSupply).to.equal(10); + await expect(server.connect(managerWallet).reduceRewardSupply(tokenId, 3)) + .to.emit(server, 'RewardSupplyChanged') + .withArgs(tokenId, 10, 7); // oldMaxSupply 10, newSupply 7 + details = await manager.getTokenDetails(SERVER_ID, tokenId); + expect(details.maxSupply).to.equal(7); + expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(7); + }); + + it('non-server-admin cannot call reduceRewardSupply', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + const rewardToken = { + tokenId, + tokenUri: 'https://example.com/1', + maxSupply: 10, + rewards: [ + { + rewardType: 1, + rewardAmount: ethers.parseEther('10'), + rewardTokenAddress: await mockERC20.getAddress(), + rewardTokenIds: [], + rewardTokenId: 0, + }, + ], + }; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); + await expect( + server.connect(user1).reduceRewardSupply(tokenId, 2) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); + }); + + it('reverts with TokenNotExist when reducing non-existent token', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + await expect(server.connect(managerWallet).reduceRewardSupply(999, 1)) + .to.be.revertedWithCustomError(server, 'TokenNotExist'); + }); + + it('reverts with InvalidAmount when _reduceBy is zero', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 1, tokenUri: 'u', maxSupply: 5, rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + await expect(server.connect(managerWallet).reduceRewardSupply(1, 0)) + .to.be.revertedWithCustomError(server, 'InvalidAmount'); + }); + + it('reverts with InsufficientBalance when new supply would be below current claims', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'u', maxSupply: 5, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 0); + await manager.connect(user1).claim(data, signature); + await manager.connect(user1).claim( + (await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 1)).data, + (await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 1)).signature + ); + await expect(server.connect(managerWallet).reduceRewardSupply(tokenId, 4)) + .to.be.revertedWithCustomError(server, 'InsufficientBalance'); + }); + + it('reduceRewardSupply ERC721 un-reserves tail NFTs', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const m = await MockERC721.deploy(); + await m.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 2); + for (let i = 0; i < 5; i++) await m.mint(managerWallet.address); + await m.connect(managerWallet).setApprovalForAll(serverAddr, true); + for (let i = 0; i < 5; i++) await m.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, i); + const tokenId = 70; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'nft', maxSupply: 5, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [0, 1, 2, 3, 4], rewardTokenId: 0 }] }, + { value: 0 } + ); + expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(5); + await server.connect(managerWallet).reduceRewardSupply(tokenId, 2); + expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(3); + await expect(server.connect(managerWallet).withdrawERC721UnreservedTreasury(await m.getAddress(), user1.address, 3)).to.not.be.reverted; + await expect(server.connect(managerWallet).withdrawERC721UnreservedTreasury(await m.getAddress(), user1.address, 4)).to.not.be.reverted; + expect(await m.ownerOf(3)).to.equal(user1.address); + expect(await m.ownerOf(4)).to.equal(user1.address); + }); + }); + + describe('increaseRewardSupply errors', function () { + it('reverts with TokenNotExist', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + await expect(server.connect(managerWallet).increaseRewardSupply(999, 1, { value: 0 })) + .to.be.revertedWithCustomError(server, 'TokenNotExist'); + }); + + it('reverts with InvalidAmount when _additionalSupply is zero', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 1, tokenUri: 'u', maxSupply: 2, rewards: [{ rewardType: 1, rewardAmount: 1n, rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + await expect(server.connect(managerWallet).increaseRewardSupply(1, 0, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InvalidAmount'); + }); + + it('reverts with InsufficientERC721Ids when increasing supply for ERC721 reward token', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const mockERC721 = await MockERC721.deploy(); + await mockERC721.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await mockERC721.getAddress(), 2); + for (let i = 0; i < 2; i++) await mockERC721.mint(managerWallet.address); + await mockERC721.connect(managerWallet).setApprovalForAll(serverAddr, true); + await mockERC721.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 0); + await mockERC721.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, 1); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 50, tokenUri: 'nft', maxSupply: 2, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await mockERC721.getAddress(), rewardTokenIds: [0, 1], rewardTokenId: 0 }] }, + { value: 0 } + ); + await expect(server.connect(managerWallet).increaseRewardSupply(50, 1, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InsufficientERC721Ids'); + }); + + it('reverts with InsufficientBalance when increasing ETH reward supply without enough msg.value', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 80, tokenUri: 'eth', maxSupply: 1, rewards: [{ rewardType: 0, rewardAmount: ethers.parseEther('0.5'), rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: ethers.parseEther('0.5') } + ); + await expect(server.connect(managerWallet).increaseRewardSupply(80, 1, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InsufficientBalance'); + }); + + it('reverts with InsufficientTreasuryBalance when increasing ERC20 supply without enough balance', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC20B = await ethers.getContractFactory('MockERC20'); + const mockB = await MockERC20B.deploy('B', 'B'); + await mockB.waitForDeployment(); + await mockB.mint(managerWallet.address, ethers.parseEther('100')); + await server.connect(managerWallet).whitelistToken(await mockB.getAddress(), 1); + await mockB.connect(managerWallet).transfer(await server.getAddress(), ethers.parseEther('20')); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 81, tokenUri: 'u', maxSupply: 2, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('10'), rewardTokenAddress: await mockB.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + await expect(server.connect(managerWallet).increaseRewardSupply(81, 5, { value: 0 })) + .to.be.revertedWithCustomError(server, 'InsufficientTreasuryBalance'); + }); + }); + + describe('server pause', function () { + it('server pause blocks claim via router even with valid signature', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'u', maxSupply: 1, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 0); + await server.connect(managerWallet).pause(); + await expect(manager.connect(user1).claim(data, signature)) + .to.be.revertedWithCustomError(server, 'EnforcedPause'); + await server.connect(managerWallet).unpause(); + await manager.connect(user1).claim(data, signature); + expect(await mockERC20.balanceOf(user1.address)).to.equal(ethers.parseEther('1')); + }); + }); + + describe('server view helpers', function () { + it('getRemainingRewardSupply returns 0 for non-existent token', async function () { + const { server } = await loadFixture(deployWithServerAndTokenFixture); + expect(await server.getRemainingRewardSupply(999)).to.equal(0); + }); + + it('getRemainingRewardSupply returns 0 when supply exhausted', async function () { + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'u', maxSupply: 1, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 0); + await manager.connect(user1).claim(data, signature); + expect(await server.getRemainingRewardSupply(tokenId)).to.equal(0); + }); + + it('getEthRequiredForIncreaseSupply returns required ETH for ETHER reward token', async function () { + const { server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'eth', maxSupply: 2, rewards: [{ rewardType: 0, rewardAmount: ethers.parseEther('0.5'), rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: ethers.parseEther('1') } + ); + expect(await server.getEthRequiredForIncreaseSupply(tokenId, 2)).to.equal(ethers.parseEther('1')); + expect(await server.getEthRequiredForIncreaseSupply(tokenId, 0)).to.equal(0); + }); + + it('getERC721RewardCurrentIndex returns index after claims', async function () { + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const mockERC721 = await MockERC721.deploy(); + await mockERC721.waitForDeployment(); + const serverAddr = await server.getAddress(); + await server.connect(managerWallet).whitelistToken(await mockERC721.getAddress(), 2); + for (let i = 0; i < 3; i++) await mockERC721.mint(managerWallet.address); + await mockERC721.connect(managerWallet).setApprovalForAll(serverAddr, true); + for (let i = 0; i < 3; i++) await mockERC721.connect(managerWallet).transferFrom(managerWallet.address, serverAddr, i); + const tokenId = 10; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'nft', maxSupply: 3, rewards: [{ rewardType: 2, rewardAmount: 1, rewardTokenAddress: await mockERC721.getAddress(), rewardTokenIds: [0, 1, 2], rewardTokenId: 0 }] }, + { value: 0 } + ); + expect(await server.getERC721RewardCurrentIndex(tokenId, 0)).to.equal(0); + await manager.connect(user1).claim((await buildClaimDataAndSignature(serverAddr, SERVER_ID, managerWallet, user1.address, [tokenId], 0)).data, (await buildClaimDataAndSignature(serverAddr, SERVER_ID, managerWallet, user1.address, [tokenId], 0)).signature); + expect(await server.getERC721RewardCurrentIndex(tokenId, 0)).to.equal(1); + }); + + it('getRewardToken returns full reward token struct', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const tokenId = 1; + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId, tokenUri: 'https://meta/1', maxSupply: 5, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('2'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + const rt = await server.getRewardToken(tokenId); + expect(rt.tokenUri).to.equal('https://meta/1'); + expect(rt.maxSupply).to.equal(5); + expect(rt.rewards.length).to.equal(1); + expect(rt.rewards[0].rewardAmount).to.equal(ethers.parseEther('2')); + }); + + it('decodeClaimData decodes correctly', async function () { + const { server } = await loadFixture(deployWithServerAndTokenFixture); + const chainId = (await ethers.provider.getNetwork()).chainId; + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint8', 'uint256[]'], + [await server.getAddress(), chainId, '0x0000000000000000000000000000000000000002', 3n, 1, [5, 6]] + ); + const d = await server.decodeClaimData(data); + expect(d.contractAddress).to.equal(await server.getAddress()); + expect(d.beneficiary).to.equal('0x0000000000000000000000000000000000000002'); + expect(d.userNonce).to.equal(3n); + expect(d.serverId).to.equal(1); + expect(d.tokenIds).to.deep.equal([5n, 6n]); + }); + + it('getAvailableTreasuryBalance returns 0 when balance <= reserved', async function () { + const { server, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 1, tokenUri: 'u', maxSupply: 1, rewards: [{ rewardType: 1, rewardAmount: ethers.parseEther('1000'), rewardTokenAddress: await mockERC20.getAddress(), rewardTokenIds: [], rewardTokenId: 0 }] }, + { value: 0 } + ); + expect(await server.getAvailableTreasuryBalance(await mockERC20.getAddress())).to.equal(0); + }); + + it('supportsInterface returns true for supported interfaces', async function () { + const { server } = await loadFixture(deployWithServerAndTokenFixture); + const IERC165 = '0x01ffc9a7'; + expect(await server.supportsInterface(IERC165)).to.equal(true); + const bogus = '0xdeadbeef'; + expect(await server.supportsInterface(bogus)).to.equal(false); + }); + + it('getAllTreasuryBalances includes multiple distinct ERC1155 reward tokens', async function () { + const { manager, server, managerWallet } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const m = await MockERC1155.deploy(); + await m.waitForDeployment(); + await server.connect(managerWallet).whitelistToken(await m.getAddress(), 3); + await m.mint(managerWallet.address, 1, 100, '0x'); + await m.mint(managerWallet.address, 2, 50, '0x'); + await m.connect(managerWallet).setApprovalForAll(await server.getAddress(), true); + await m.connect(managerWallet).safeTransferFrom(managerWallet.address, await server.getAddress(), 1, 100, '0x'); + await m.connect(managerWallet).safeTransferFrom(managerWallet.address, await server.getAddress(), 2, 50, '0x'); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 90, tokenUri: '1155a', maxSupply: 2, rewards: [{ rewardType: 3, rewardAmount: 10, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [], rewardTokenId: 1 }] }, + { value: 0 } + ); + await server.connect(managerWallet).createTokenAndReserveRewards( + { tokenId: 91, tokenUri: '1155b', maxSupply: 1, rewards: [{ rewardType: 3, rewardAmount: 5, rewardTokenAddress: await m.getAddress(), rewardTokenIds: [], rewardTokenId: 2 }] }, + { value: 0 } + ); + const [addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds] = await manager.getServerTreasuryBalances(SERVER_ID); + expect(addresses.length).to.be.gte(3); + const erc1155Count = tokenIds.filter((id: bigint) => id > 0n).length; + expect(erc1155Count).to.equal(2); + }); + }); +}); +